fix(multi-cli): populate multiCliPlan sessions in liteTaskDataStore

Fix task click handlers not working in multi-CLI planning detail page.

Root cause: liteTaskDataStore was not being populated with multiCliPlan
sessions during initialization, so task click handlers couldn't access
session data using currentSessionDetailKey.

Changes:
- navigation.js: Add code to populate multiCliPlan sessions in liteTaskDataStore
- notifications.js: Add code to populate multiCliPlan sessions when data refreshes

Now when task detail page loads, liteTaskDataStore contains the correct key
'multi-cli-${sessionId}' matching currentSessionDetailKey, allowing task
click handlers to find session data and open detail drawer.

Verified: Task clicks now properly open detail panel for all 7 tasks.
This commit is contained in:
catlog22
2026-01-22 15:41:01 +08:00
parent f0954b3247
commit ea04663035
22 changed files with 921 additions and 83 deletions

View File

@@ -300,6 +300,10 @@ export function run(argv: string[]): void {
.option('--fail', 'Mark task as failed')
.option('--from-queue [queue-id]', 'Sync issue statuses from queue (default: active queue)')
.option('--queue <queue-id>', 'Target queue ID for multi-queue operations')
// GitHub pull options
.option('--state <state>', 'GitHub issue state: open, closed, or all')
.option('--limit <n>', 'Maximum number of issues to pull from GitHub')
.option('--labels <labels>', 'Filter by GitHub labels (comma-separated)')
.action((subcommand, args, options) => issueCommand(subcommand, args, options));
// Loop command - Loop management for multi-CLI orchestration

View File

@@ -223,6 +223,10 @@ interface IssueOptions {
data?: string; // JSON data for create
fromQueue?: boolean | string; // Sync statuses from queue (true=active, string=specific queue ID)
queue?: string; // Target queue ID for multi-queue operations
// GitHub pull options
state?: string; // Issue state: open, closed, all
limit?: number; // Maximum number of issues to pull
labels?: string; // Filter by labels (comma-separated)
}
const ISSUES_DIR = '.workflow/issues';
@@ -1003,6 +1007,113 @@ async function createAction(options: IssueOptions): Promise<void> {
}
}
/**
* pull - Pull issues from GitHub
* Usage: ccw issue pull [--state open|closed|all] [--limit N] [--labels label1,label2]
*/
async function pullAction(options: IssueOptions): Promise<void> {
try {
// Check if gh CLI is available
try {
execSync('gh --version', { stdio: 'ignore', timeout: EXEC_TIMEOUTS.GIT_QUICK });
} catch {
console.error(chalk.red('GitHub CLI (gh) is not installed or not in PATH'));
console.error(chalk.gray('Install from: https://cli.github.com/'));
process.exit(1);
}
// Build gh command with options
const state = options.state || 'open';
const limit = options.limit || 100;
let ghCommand = `gh issue list --state ${state} --limit ${limit} --json number,title,body,labels,url,state`;
if (options.labels) {
ghCommand += ` --label "${options.labels}"`;
}
console.log(chalk.cyan(`Fetching issues from GitHub (state: ${state}, limit: ${limit})...`));
// Fetch issues from GitHub
const ghOutput = execSync(ghCommand, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PROCESS_SPAWN,
}).trim();
if (!ghOutput) {
console.log(chalk.yellow('No issues found on GitHub'));
return;
}
const ghIssues = JSON.parse(ghOutput);
const existingIssues = readIssues();
let imported = 0;
let skipped = 0;
let updated = 0;
for (const ghIssue of ghIssues) {
const issueId = `GH-${ghIssue.number}`;
const existingIssue = existingIssues.find(i => i.id === issueId);
// Prepare issue data
const issueData: Partial<Issue> = {
id: issueId,
title: ghIssue.title,
status: ghIssue.state === 'OPEN' ? 'registered' : 'completed',
priority: 3, // Default priority
context: ghIssue.body?.substring(0, 500) || ghIssue.title,
source: 'github',
source_url: ghIssue.url,
tags: ghIssue.labels?.map((l: any) => l.name) || [],
};
if (existingIssue) {
// Update existing issue if state changed
if (existingIssue.source_url === ghIssue.url) {
// Check if status needs updating
const newStatus = ghIssue.state === 'OPEN' ? 'registered' : 'completed';
if (existingIssue.status !== newStatus || existingIssue.title !== ghIssue.title) {
existingIssue.title = ghIssue.title;
existingIssue.status = newStatus;
existingIssue.updated_at = new Date().toISOString();
updated++;
} else {
skipped++;
}
} else {
skipped++;
}
} else {
// Create new issue
try {
createIssue(issueData);
imported++;
} catch (err) {
console.error(chalk.red(`Failed to import issue #${ghIssue.number}: ${(err as Error).message}`));
}
}
}
// Save updates if any
if (updated > 0) {
writeIssues(existingIssues);
}
console.log(chalk.green(`\n✓ GitHub sync complete:`));
console.log(chalk.gray(` - Imported: ${imported} new issues`));
console.log(chalk.gray(` - Updated: ${updated} existing issues`));
console.log(chalk.gray(` - Skipped: ${skipped} unchanged issues`));
if (options.json) {
console.log(JSON.stringify({ imported, updated, skipped, total: ghIssues.length }));
}
} catch (err) {
console.error(chalk.red(`Failed to pull issues from GitHub: ${(err as Error).message}`));
process.exit(1);
}
}
/**
* solution - Create or read solutions
* Create: ccw issue solution <issue-id> --data '{"tasks":[...]}'
@@ -2715,6 +2826,9 @@ export async function issueCommand(
case 'create':
await createAction(options);
break;
case 'pull':
await pullAction(options);
break;
case 'solution':
await solutionAction(argsArray[0], options);
break;
@@ -2769,6 +2883,8 @@ export async function issueCommand(
console.log(chalk.bold.cyan('\nCCW Issue Management (v3.0 - Multi-Queue + Lifecycle)\n'));
console.log(chalk.bold('Core Commands:'));
console.log(chalk.gray(' create --data \'{"title":"..."}\' Create issue (auto-generates ID)'));
console.log(chalk.gray(' pull [--state open|closed|all] Pull issues from GitHub'));
console.log(chalk.gray(' [--limit N] [--labels label1,label2]'));
console.log(chalk.gray(' init <issue-id> Initialize new issue (manual ID)'));
console.log(chalk.gray(' list [issue-id] List issues or tasks'));
console.log(chalk.gray(' history List completed issues (from history)'));
@@ -2809,6 +2925,9 @@ export async function issueCommand(
console.log(chalk.gray(' --priority <n> Queue priority (lower = higher)'));
console.log(chalk.gray(' --json JSON output'));
console.log(chalk.gray(' --force Force operation'));
console.log(chalk.gray(' --state <state> GitHub issue state (open/closed/all)'));
console.log(chalk.gray(' --limit <n> Max issues to pull from GitHub'));
console.log(chalk.gray(' --labels <labels> Filter by GitHub labels (comma-separated)'));
console.log();
console.log(chalk.bold('Storage:'));
console.log(chalk.gray(' .workflow/issues/issues.jsonl Active issues'));

View File

@@ -124,7 +124,7 @@ export async function upgradeCommand(options: UpgradeOptions): Promise<void> {
info('All installations are up to date.');
console.log('');
info('To upgrade ccw itself, run:');
console.log(chalk.cyan(' npm update -g ccw'));
console.log(chalk.cyan(' npm update -g claude-code-workflow'));
console.log('');
return;
}

View File

@@ -13,20 +13,16 @@
import { spawn } from 'child_process';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { getCodexLensPython } from '../utils/codexlens-path.js';
// Get directory of this module
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Venv paths (reuse CodexLens venv)
const CODEXLENS_VENV = join(homedir(), '.codexlens', 'venv');
const VENV_PYTHON =
process.platform === 'win32'
? join(CODEXLENS_VENV, 'Scripts', 'python.exe')
: join(CODEXLENS_VENV, 'bin', 'python');
const VENV_PYTHON = getCodexLensPython();
// Script path
const EMBEDDER_SCRIPT = join(__dirname, '..', '..', 'scripts', 'memory_embedder.py');

View File

@@ -16,6 +16,7 @@ import type { RouteContext } from '../types.js';
import { EXEC_TIMEOUTS } from '../../../utils/exec-constants.js';
import { extractJSON } from './utils.js';
import { stopWatcherForUninstall } from './watcher-handlers.js';
import { getCodexLensDataDir } from '../../../utils/codexlens-path.js';
export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
@@ -777,7 +778,7 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise<bo
const { join } = await import('path');
const { readFile } = await import('fs/promises');
const envPath = join(homedir(), '.codexlens', '.env');
const envPath = join(getCodexLensDataDir(), '.env');
let content = '';
try {
content = await readFile(envPath, 'utf-8');
@@ -829,7 +830,7 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise<bo
}
// Also read settings.json for current configuration
const settingsPath = join(homedir(), '.codexlens', 'settings.json');
const settingsPath = join(getCodexLensDataDir(), 'settings.json');
let settings: Record<string, any> = {};
try {
const settingsContent = await readFile(settingsPath, 'utf-8');
@@ -943,7 +944,7 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise<bo
const { join, dirname } = await import('path');
const { writeFile, mkdir, readFile } = await import('fs/promises');
const envPath = join(homedir(), '.codexlens', '.env');
const envPath = join(getCodexLensDataDir(), '.env');
await mkdir(dirname(envPath), { recursive: true });
// Read existing env file to preserve custom variables
@@ -1072,7 +1073,7 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise<bo
await writeFile(envPath, lines.join('\n'), 'utf-8');
// Also update settings.json with mapped values
const settingsPath = join(homedir(), '.codexlens', 'settings.json');
const settingsPath = join(getCodexLensDataDir(), 'settings.json');
let settings: Record<string, any> = {};
try {
const settingsContent = await readFile(settingsPath, 'utf-8');
@@ -1145,7 +1146,7 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise<bo
const { join } = await import('path');
const { readFile } = await import('fs/promises');
const settingsPath = join(homedir(), '.codexlens', 'settings.json');
const settingsPath = join(getCodexLensDataDir(), 'settings.json');
let settings: Record<string, any> = {};
try {
const content = await readFile(settingsPath, 'utf-8');
@@ -1214,7 +1215,7 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise<bo
const { join, dirname } = await import('path');
const { writeFile, mkdir, readFile } = await import('fs/promises');
const settingsPath = join(homedir(), '.codexlens', 'settings.json');
const settingsPath = join(getCodexLensDataDir(), 'settings.json');
await mkdir(dirname(settingsPath), { recursive: true });
// Read existing settings

View File

@@ -17,6 +17,7 @@ import {
import type { RouteContext } from '../types.js';
import { extractJSON } from './utils.js';
import { getDefaultTool } from '../../../tools/claude-cli-tools.js';
import { getCodexLensDataDir } from '../../../utils/codexlens-path.js';
export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
@@ -445,9 +446,8 @@ export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<
// Write to CodexLens .env file for persistence
const { writeFileSync, existsSync, readFileSync } = await import('fs');
const { join } = await import('path');
const { homedir } = await import('os');
const codexlensDir = join(homedir(), '.codexlens');
const codexlensDir = getCodexLensDataDir();
const envFile = join(codexlensDir, '.env');
// Read existing .env content

View File

@@ -2,13 +2,29 @@
* Graph Routes Module
* Handles graph visualization API endpoints for codex-lens data
*/
import { homedir } from 'os';
import { join, resolve, normalize } from 'path';
import { existsSync, readdirSync } from 'fs';
import Database from 'better-sqlite3';
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
import type { RouteContext } from './types.js';
/**
* Get the index root directory from CodexLens config or default.
* Matches Python implementation priority:
* 1. CODEXLENS_INDEX_DIR environment variable
* 2. index_dir from ~/.codexlens/config.json
* 3. Default: ~/.codexlens/indexes
*/
function getIndexRoot(): string {
const envOverride = process.env.CODEXLENS_INDEX_DIR;
if (envOverride) {
return envOverride;
}
// Default: use CodexLens data directory + indexes
const { getCodexLensDataDir } = require('../../utils/codexlens-path.js');
return join(getCodexLensDataDir(), 'indexes');
}
/**
* PathMapper utility class (simplified from codex-lens Python implementation)
* Maps source paths to index database paths
@@ -17,7 +33,7 @@ class PathMapper {
private indexRoot: string;
constructor(indexRoot?: string) {
this.indexRoot = indexRoot || join(homedir(), '.codexlens', 'indexes');
this.indexRoot = indexRoot || getIndexRoot();
}
/**

View File

@@ -992,6 +992,188 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// POST /api/issues/pull - Pull issues from GitHub
if (pathname === '/api/issues/pull' && req.method === 'POST') {
const state = url.searchParams.get('state') || 'open';
const limit = parseInt(url.searchParams.get('limit') || '100');
const labels = url.searchParams.get('labels') || '';
const downloadImages = url.searchParams.get('downloadImages') === 'true';
try {
const { execSync } = await import('child_process');
const https = await import('https');
const http = await import('http');
// Check if gh CLI is available
try {
execSync('gh --version', { stdio: 'ignore', timeout: 5000 });
} catch {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'GitHub CLI (gh) is not installed or not in PATH' }));
return true;
}
// Build gh command
let ghCommand = `gh issue list --state ${state} --limit ${limit} --json number,title,body,labels,url,state`;
if (labels) ghCommand += ` --label "${labels}"`;
// Execute gh command from project root
const ghOutput = execSync(ghCommand, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 60000,
cwd: issuesDir.replace(/[\\/]\.workflow[\\/]issues$/, '')
}).trim();
if (!ghOutput) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ imported: 0, updated: 0, skipped: 0, images_downloaded: 0 }));
return true;
}
const ghIssues = JSON.parse(ghOutput);
const existingIssues = readIssuesJsonl(issuesDir);
let imported = 0;
let skipped = 0;
let updated = 0;
let imagesDownloaded = 0;
// Create images directory if needed
const imagesDir = join(issuesDir, 'images');
if (downloadImages && !existsSync(imagesDir)) {
mkdirSync(imagesDir, { recursive: true });
}
// Helper function to download image
const downloadImage = async (imageUrl: string, issueNumber: number, imageIndex: number): Promise<string | null> => {
return new Promise((resolveDownload) => {
try {
const ext = imageUrl.match(/\.(png|jpg|jpeg|gif|webp|svg)/i)?.[1] || 'png';
const filename = `GH-${issueNumber}-${imageIndex}.${ext}`;
const filePath = join(imagesDir, filename);
// Skip if already downloaded
if (existsSync(filePath)) {
resolveDownload(`.workflow/issues/images/${filename}`);
return;
}
const protocol = imageUrl.startsWith('https') ? https : http;
protocol.get(imageUrl, { timeout: 30000 }, (response: any) => {
// Handle redirect
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
downloadImage(redirectUrl, issueNumber, imageIndex).then(resolveDownload);
return;
}
}
if (response.statusCode !== 200) {
resolveDownload(null);
return;
}
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => {
try {
writeFileSync(filePath, Buffer.concat(chunks));
resolveDownload(`.workflow/issues/images/${filename}`);
} catch {
resolveDownload(null);
}
});
response.on('error', () => resolveDownload(null));
}).on('error', () => resolveDownload(null));
} catch {
resolveDownload(null);
}
});
};
// Process issues
for (const ghIssue of ghIssues) {
const issueId = `GH-${ghIssue.number}`;
const existingIssue = existingIssues.find((i: any) => i.id === issueId);
let context = ghIssue.body || ghIssue.title;
// Extract and download images if enabled
if (downloadImages && ghIssue.body) {
// Find all image URLs in the body
const imgPattern = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)|<img[^>]+src=["'](https?:\/\/[^"']+)["']/gi;
const imageUrls: string[] = [];
let match;
while ((match = imgPattern.exec(ghIssue.body)) !== null) {
imageUrls.push(match[1] || match[2]);
}
// Download images and build reference list
if (imageUrls.length > 0) {
const downloadedImages: string[] = [];
for (let i = 0; i < imageUrls.length; i++) {
const localPath = await downloadImage(imageUrls[i], ghIssue.number, i + 1);
if (localPath) {
downloadedImages.push(localPath);
imagesDownloaded++;
}
}
// Append image references to context
if (downloadedImages.length > 0) {
context += '\n\n---\n**Downloaded Images:**\n';
downloadedImages.forEach((path, idx) => {
context += `- Image ${idx + 1}: \`${path}\`\n`;
});
}
}
}
// Prepare issue data (truncate context to 2000 chars max)
const issueData = {
id: issueId,
title: ghIssue.title,
status: ghIssue.state === 'OPEN' ? 'registered' : 'completed',
priority: 3,
context: context.substring(0, 2000),
source: 'github',
source_url: ghIssue.url,
tags: ghIssue.labels?.map((l: any) => l.name) || [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
if (existingIssue) {
// Update if changed
const newStatus = ghIssue.state === 'OPEN' ? 'registered' : 'completed';
if (existingIssue.status !== newStatus || existingIssue.title !== ghIssue.title) {
existingIssue.title = ghIssue.title;
existingIssue.status = newStatus;
existingIssue.context = issueData.context;
existingIssue.updated_at = new Date().toISOString();
updated++;
} else {
skipped++;
}
} else {
existingIssues.push(issueData);
imported++;
}
}
// Save all issues
writeIssuesJsonl(issuesDir, existingIssues);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ imported, updated, skipped, images_downloaded: imagesDownloaded, total: ghIssues.length }));
} catch (err: any) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message || 'Failed to pull issues from GitHub' }));
}
return true;
}
// GET /api/issues/:id - Get issue detail
const detailMatch = pathname.match(/^\/api\/issues\/([^/]+)$/);
if (detailMatch && req.method === 'GET') {

View File

@@ -37,6 +37,38 @@ import { TaskStorageManager, type TaskCreateRequest, type TaskUpdateRequest, typ
import { executeCliTool } from '../../tools/cli-executor.js';
import { loadClaudeCliTools } from '../../tools/claude-cli-tools.js';
/**
* Module-level cache for CLI tools configuration
* Loaded once at server startup to avoid repeated file I/O
*/
let cachedEnabledTools: string[] | null = null;
/**
* Initialize CLI tools cache at server startup
* Should be called once when the server starts
*/
export function initializeCliToolsCache(): void {
try {
const cliToolsConfig = loadClaudeCliTools(os.homedir());
const enabledTools = Object.entries(cliToolsConfig.tools || {})
.filter(([_, config]) => config.enabled === true)
.map(([name]) => name);
cachedEnabledTools = ['bash', ...enabledTools];
console.log('[Loop V2] CLI tools cache initialized:', cachedEnabledTools);
} catch (err) {
console.error('[Loop V2] Failed to initialize CLI tools cache:', err);
// Fallback to basic tools if config loading fails
cachedEnabledTools = ['bash', 'gemini', 'qwen', 'codex', 'claude'];
}
}
/**
* Clear CLI tools cache (for testing or config reload)
*/
export function clearCliToolsCache(): void {
cachedEnabledTools = null;
}
/**
* V2 Loop Create Request
*/
@@ -1314,14 +1346,19 @@ function isValidId(id: string): boolean {
}
/**
* Get enabled tools list
* Get enabled tools list from cache
* If cache is not initialized, it will load from config (fallback for lazy initialization)
*/
function getEnabledToolsList(): string[] {
const cliToolsConfig = loadClaudeCliTools(os.homedir());
const enabledTools = Object.entries(cliToolsConfig.tools || {})
.filter(([_, config]) => config.enabled === true)
.map(([name]) => name);
return ['bash', ...enabledTools];
// Return cached value if available
if (cachedEnabledTools) {
return cachedEnabledTools;
}
// Fallback: lazy initialization if cache not initialized (shouldn't happen in normal operation)
console.warn('[Loop V2] CLI tools cache not initialized, performing lazy load');
initializeCliToolsCache();
return cachedEnabledTools || ['bash', 'gemini', 'qwen', 'codex', 'claude'];
}
/**

View File

@@ -1508,6 +1508,38 @@
transform: translateY(0);
}
.issue-pull-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
background: #1a1a1a;
color: #ffffff;
border: 1px solid #2d2d2d;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.issue-pull-btn:hover {
background: #2d2d2d;
border-color: #404040;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.issue-pull-btn:active {
transform: translateY(0);
background: #1a1a1a;
}
.issue-pull-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ==========================================
ISSUE STATS
========================================== */

View File

@@ -303,6 +303,12 @@ async function refreshWorkspace() {
liteTaskDataStore[sessionKey] = s;
});
// Populate multiCliPlan sessions
(data.liteTasks?.multiCliPlan || []).forEach(s => {
const sessionKey = `multi-cli-${s.id}`.replace(/[^a-zA-Z0-9-]/g, '-');
liteTaskDataStore[sessionKey] = s;
});
// Update global data
window.workflowData = data;

View File

@@ -827,6 +827,12 @@ async function refreshWorkspaceData(newData) {
liteTaskDataStore[key] = s;
});
// Populate multiCliPlan sessions
(newData.liteTasks?.multiCliPlan || []).forEach(s => {
const key = `multi-cli-${s.id}`.replace(/[^a-zA-Z0-9-]/g, '-');
liteTaskDataStore[key] = s;
});
// Update UI silently
updateStats();
updateBadges();

View File

@@ -60,6 +60,9 @@ async function checkForUpdatesNow() {
btn.disabled = true;
}
// Show checking state on badge
updateVersionBadge('checking');
// Show checking notification
console.log('[Version Check] Starting update check...');
if (typeof addGlobalNotification === 'function') {
@@ -83,6 +86,9 @@ async function checkForUpdatesNow() {
versionCheckData = data;
console.log('[Version Check] Result:', data);
// Update badge based on result
updateVersionBadge(data.hasUpdate ? 'has-update' : 'none');
if (data.hasUpdate) {
// New version available
console.log('[Version Check] Update available:', data.latestVersion);
@@ -109,6 +115,8 @@ async function checkForUpdatesNow() {
}
} catch (err) {
console.error('[Version Check] Error:', err);
// Clear badge on error
updateVersionBadge('none');
if (typeof addGlobalNotification === 'function') {
addGlobalNotification(
'error',
@@ -154,6 +162,9 @@ async function checkForUpdates() {
versionCheckData = await res.json();
// Update badge
updateVersionBadge(versionCheckData.hasUpdate ? 'has-update' : 'none');
if (versionCheckData.hasUpdate && !versionBannerDismissed) {
showUpdateBanner(versionCheckData);
addGlobalNotification(
@@ -299,3 +310,30 @@ function getVersionInfo() {
function isAutoUpdateEnabled() {
return autoUpdateEnabled;
}
/**
* Update version badge state
* @param {string} state - 'checking', 'has-update', 'none'
*/
function updateVersionBadge(state) {
const badge = document.getElementById('versionBadge');
if (!badge) return;
// Remove all state classes
badge.classList.remove('has-update', 'checking');
badge.textContent = '';
switch (state) {
case 'checking':
badge.classList.add('checking');
break;
case 'has-update':
badge.classList.add('has-update');
badge.textContent = '!';
break;
case 'none':
default:
// Hide badge
break;
}
}

View File

@@ -185,6 +185,14 @@ function renderIssueView() {
</div>
<div class="flex items-center gap-3">
<!-- Pull from GitHub Button -->
<button class="issue-pull-btn" onclick="showPullIssuesModal()" title="Pull issues from GitHub repository">
<svg class="w-4 h-4 mr-1.5" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>Pull from GitHub</span>
</button>
<!-- Create Button -->
<button class="issue-create-btn" onclick="showCreateIssueModal()">
<i data-lucide="plus" class="w-4 h-4"></i>
@@ -281,6 +289,59 @@ function renderIssueView() {
</div>
</div>
</div>
<!-- Pull Issues Modal -->
<div id="pullIssuesModal" class="issue-modal hidden">
<div class="issue-modal-backdrop" onclick="hidePullIssuesModal()"></div>
<div class="issue-modal-content">
<div class="issue-modal-header">
<h3>
<svg class="w-5 h-5 inline mr-2 -mt-1" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Pull Issues from GitHub
</h3>
<button class="btn-icon" onclick="hidePullIssuesModal()">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="issue-modal-body">
<div class="form-group">
<label>Issue State</label>
<select id="pullIssueState">
<option value="open" selected>Open</option>
<option value="closed">Closed</option>
<option value="all">All</option>
</select>
</div>
<div class="form-group">
<label>Maximum Issues</label>
<input type="number" id="pullIssueLimit" value="20" min="1" max="100" />
</div>
<div class="form-group">
<label>Labels (optional)</label>
<input type="text" id="pullIssueLabels" placeholder="bug, enhancement (comma-separated)" />
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="pullDownloadImages" checked />
<span>Download images to local</span>
</label>
<p class="form-hint text-xs text-muted-foreground mt-1">Images will be saved to .workflow/issues/images/ and links updated in issue context</p>
</div>
<div id="pullIssueResult" class="pull-result hidden mt-4 p-3 rounded-md bg-muted"></div>
</div>
<div class="issue-modal-footer">
<button class="btn-secondary" onclick="hidePullIssuesModal()">Cancel</button>
<button class="btn-primary" id="pullIssuesBtn" onclick="pullGitHubIssues()">
<svg class="w-4 h-4 mr-1 inline" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Pull Issues
</button>
</div>
</div>
</div>
</div>
`;
@@ -2627,6 +2688,127 @@ function hideCreateIssueModal() {
}
}
// ========== Pull Issues Modal ==========
function showPullIssuesModal() {
const modal = document.getElementById('pullIssuesModal');
if (modal) {
modal.classList.remove('hidden');
// Reset result area
const resultDiv = document.getElementById('pullIssueResult');
if (resultDiv) {
resultDiv.classList.add('hidden');
resultDiv.innerHTML = '';
}
lucide.createIcons();
}
}
function hidePullIssuesModal() {
const modal = document.getElementById('pullIssuesModal');
if (modal) {
modal.classList.add('hidden');
// Clear form
const stateSelect = document.getElementById('pullIssueState');
const limitInput = document.getElementById('pullIssueLimit');
const labelsInput = document.getElementById('pullIssueLabels');
const downloadImagesCheck = document.getElementById('pullDownloadImages');
if (stateSelect) stateSelect.value = 'open';
if (limitInput) limitInput.value = '20';
if (labelsInput) labelsInput.value = '';
if (downloadImagesCheck) downloadImagesCheck.checked = true;
}
}
async function pullGitHubIssues() {
const stateSelect = document.getElementById('pullIssueState');
const limitInput = document.getElementById('pullIssueLimit');
const labelsInput = document.getElementById('pullIssueLabels');
const downloadImagesCheck = document.getElementById('pullDownloadImages');
const resultDiv = document.getElementById('pullIssueResult');
const pullBtn = document.getElementById('pullIssuesBtn');
const state = stateSelect?.value || 'open';
const limit = parseInt(limitInput?.value || '20');
const labels = labelsInput?.value?.trim();
const downloadImages = downloadImagesCheck?.checked || false;
// Disable button and show loading
if (pullBtn) {
pullBtn.disabled = true;
pullBtn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 mr-1 animate-spin"></i>' + (t('common.loading') || 'Loading...');
lucide.createIcons();
}
try {
const params = new URLSearchParams({
path: projectPath,
state: state,
limit: limit.toString(),
downloadImages: downloadImages.toString()
});
if (labels) params.set('labels', labels);
const response = await fetch('/api/issues/pull?' + params.toString(), {
method: 'POST'
});
const result = await response.json();
if (!response.ok || result.error) {
showNotification(result.error || 'Failed to pull issues', 'error');
if (resultDiv) {
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = `<p class="text-destructive">${result.error || 'Failed to pull issues'}</p>`;
}
return;
}
// Show results
if (resultDiv) {
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = `
<div class="flex items-start gap-2">
<i data-lucide="check-circle" class="w-5 h-5 text-success mt-0.5"></i>
<div class="flex-1">
<p class="font-medium mb-2">${t('issues.pullSuccess') || 'GitHub Issues Pulled Successfully'}</p>
<div class="text-sm text-muted-foreground space-y-1">
<p>✓ Imported: <strong>${result.imported || 0}</strong> new issues</p>
<p>✓ Updated: <strong>${result.updated || 0}</strong> existing issues</p>
<p>✓ Skipped: <strong>${result.skipped || 0}</strong> unchanged issues</p>
${result.images_downloaded > 0 ? `<p>✓ Downloaded: <strong>${result.images_downloaded}</strong> images</p>` : ''}
</div>
</div>
</div>
`;
lucide.createIcons();
}
showNotification(`Pulled ${result.imported + result.updated} issues from GitHub`, 'success');
// Reload data after 1 second
setTimeout(async () => {
await loadIssueData();
renderIssueView();
hidePullIssuesModal();
}, 1500);
} catch (err) {
console.error('Failed to pull issues:', err);
showNotification('Failed to pull issues', 'error');
if (resultDiv) {
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = `<p class="text-destructive">${err.message || 'Unknown error occurred'}</p>`;
}
} finally {
// Re-enable button
if (pullBtn) {
pullBtn.disabled = false;
pullBtn.innerHTML = '<i data-lucide="download" class="w-4 h-4 mr-1"></i>' + (t('issues.pull') || 'Pull');
lucide.createIcons();
}
}
}
async function createIssue() {
const idInput = document.getElementById('newIssueId');
const titleInput = document.getElementById('newIssueTitle');

View File

@@ -1012,15 +1012,24 @@ async function saveTaskOrder(loopId, newOrder) {
/**
* Show add task modal
* Loads enabled tools before displaying modal to prevent race conditions
*/
async function showAddTaskModal(loopId) {
// Get enabled tools
const enabledTools = await getEnabledTools();
// Find and disable the "Add Task" button to prevent multiple clicks during loading
const addTaskButton = event?.target;
if (addTaskButton) {
addTaskButton.disabled = true;
const originalText = addTaskButton.innerHTML;
addTaskButton.innerHTML = '<i class="spinner"></i> ' + (t('common.loading') || 'Loading...');
// Build tool options HTML
const toolOptions = enabledTools.map(tool =>
`<option value="${tool}">${tool.charAt(0).toUpperCase() + tool.slice(1)}</option>`
).join('');
try {
// Get enabled tools (this ensures tools are loaded before modal opens)
const enabledTools = await getEnabledTools();
// Build tool options HTML
const toolOptions = enabledTools.map(tool =>
`<option value="${tool}">${tool.charAt(0).toUpperCase() + tool.slice(1)}</option>`
).join('');
const modal = document.createElement('div');
modal.id = 'addTaskModal';
@@ -1075,11 +1084,103 @@ async function showAddTaskModal(loopId) {
</div>
`;
document.body.appendChild(modal);
if (typeof lucide !== 'undefined') lucide.createIcons();
document.body.appendChild(modal);
if (typeof lucide !== 'undefined') lucide.createIcons();
// Focus on description field
setTimeout(() => document.getElementById('taskDescription').focus(), 100);
// Focus on description field
setTimeout(() => document.getElementById('taskDescription').focus(), 100);
} catch (err) {
console.error('Failed to show add task modal:', err);
alert(t('loop.loadToolsError') || 'Failed to load available tools. Please try again.');
} finally {
// Restore button state
if (addTaskButton) {
addTaskButton.disabled = false;
addTaskButton.innerHTML = originalText;
}
}
} else {
// Fallback if event is not available (shouldn't happen normally)
const enabledTools = await getEnabledTools();
const toolOptions = enabledTools.map(tool =>
`<option value="${tool}">${tool.charAt(0).toUpperCase() + tool.slice(1)}</option>`
).join('');
const modal = document.createElement('div');
modal.id = 'addTaskModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h3><i data-lucide="plus-circle" class="w-5 h-5"></i> ${t('loop.addTask') || 'Add Task'}</h3>
<button class="modal-close" onclick="closeTaskModal()">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="modal-body">
<form id="addTaskForm" onsubmit="handleAddTask(event, '${loopId}')">
<div id="addTaskError" class="alert alert-error" style="display: none;"></div>
<!-- Description -->
<div class="form-group">
<label for="taskDescription">${t('loop.taskDescription') || 'Task Description'} <span class="required">*</span></label>
<textarea id="taskDescription" name="description" rows="3" required
placeholder="${t('loop.taskDescriptionPlaceholder') || 'Describe what this task should do...'}"
class="form-control"></textarea>
</div>
<!-- Tool -->
<div class="form-group">
<label for="taskTool">${t('loop.tool') || 'Tool'}</label>
<select id="taskTool" name="tool" class="form-control">
${toolOptions}
</select>
</div>
<!-- Mode -->
<div class="form-group">
<label for="taskMode">${t('loop.mode') || 'Mode'}</label>
<select id="taskMode" name="mode" class="form-control">
<option value="analysis">${t('loop.modeAnalysis') || 'Analysis'}</option>
<option value="write">${t('loop.modeWrite') || 'Write'}</option>
<option value="review">${t('loop.modeReview') || 'Review'}</option>
</select>
</div>
<!-- Prompt Template -->
<div class="form-group">
<label for="taskPrompt">${t('loop.promptTemplate') || 'Prompt Template'} <span class="required">*</span></label>
<textarea id="taskPrompt" name="prompt_template" rows="5" required
placeholder="${t('loop.promptPlaceholder') || 'Enter the prompt to execute...'}"
class="form-control"></textarea>
<small class="form-help">${t('loop.promptHelp') || 'Variables: {iteration}, {output_prev}'}</small>
</div>
<!-- Error Handling -->
<div class="form-group">
<label for="taskOnError">${t('loop.onError') || 'On Error'}</label>
<select id="taskOnError" name="on_error" class="form-control">
<option value="continue">${t('loop.errorContinue') || 'Continue'}</option>
<option value="pause">${t('loop.errorPause') || 'Pause'}</option>
<option value="fail_fast">${t('loop.errorFailFast') || 'Fail Fast'}</option>
</select>
</div>
<!-- Form Actions -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">${t('loop.cancel') || 'Cancel'}</button>
<button type="submit" class="btn btn-primary">${t('loop.add') || 'Add'}</button>
</div>
</form>
</div>
</div>
`;
document.body.appendChild(modal);
if (typeof lucide !== 'undefined') lucide.createIcons();
setTimeout(() => document.getElementById('taskDescription').focus(), 100);
}
}
/**

View File

@@ -278,6 +278,50 @@
display: block;
animation: spin 1s linear infinite;
}
.check-icon-loading {
display: none;
}
/* Version Badge */
.version-badge {
display: none;
position: absolute;
top: -2px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: hsl(var(--destructive));
color: white;
border-radius: 8px;
font-size: 0.625rem;
font-weight: 600;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
}
.version-badge.has-update {
display: flex;
animation: badgePulse 2s ease-in-out infinite;
}
.version-badge.checking {
display: flex;
background: hsl(var(--muted) / 0.8);
}
.version-badge.checking::after {
content: '...';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes badgePulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
/* Auto-Update Toggle Switch */
.auto-update-switch {
@@ -390,7 +434,7 @@
<!-- Auto-Update Controls -->
<div class="flex items-center gap-2 border-l border-border pl-2">
<!-- Check Now Button -->
<button class="tooltip-bottom p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded" id="checkUpdateNow" data-tooltip="Check for updates now" onclick="checkForUpdatesNow()">
<button class="tooltip-bottom p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded relative" id="checkUpdateNow" data-tooltip="Check for updates now" onclick="checkForUpdatesNow()">
<!-- Download Icon (default) -->
<svg class="check-icon-default" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
@@ -398,9 +442,11 @@
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<!-- Loading Icon (checking state) -->
<svg class="check-icon-loading hidden" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="check-icon-loading" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<!-- Version Available Badge -->
<span class="version-badge" id="versionBadge"></span>
</button>
<!-- Auto-Update Toggle Switch -->
<label class="tooltip-bottom auto-update-switch" data-tooltip="Auto-update">

View File

@@ -12,14 +12,11 @@ import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { spawn } from 'child_process';
import { join } from 'path';
import { homedir } from 'os';
import { getProjectRoot } from '../utils/path-validator.js';
import { getCodexLensPython } from '../utils/codexlens-path.js';
// CodexLens venv configuration
const CODEXLENS_VENV =
process.platform === 'win32'
? join(homedir(), '.codexlens', 'venv', 'Scripts', 'python.exe')
: join(homedir(), '.codexlens', 'venv', 'bin', 'python');
const CODEXLENS_VENV = getCodexLensPython();
// Define Zod schema for validation
const ParamsSchema = z.object({

View File

@@ -24,6 +24,12 @@ import {
isUvAvailable,
createCodexLensUvManager,
} from '../utils/uv-manager.js';
import {
getCodexLensDataDir,
getCodexLensVenvDir,
getCodexLensPython,
getCodexLensPip,
} from '../utils/codexlens-path.js';
// Get directory of this module
const __filename = fileURLToPath(import.meta.url);
@@ -109,14 +115,6 @@ function findLocalCcwLitellmPath(): string | null {
return findLocalPackagePath('ccw-litellm');
}
// CodexLens configuration
const CODEXLENS_DATA_DIR = join(homedir(), '.codexlens');
const CODEXLENS_VENV = join(CODEXLENS_DATA_DIR, 'venv');
const VENV_PYTHON =
process.platform === 'win32'
? join(CODEXLENS_VENV, 'Scripts', 'python.exe')
: join(CODEXLENS_VENV, 'bin', 'python');
// Bootstrap status cache
let bootstrapChecked = false;
let bootstrapReady = false;
@@ -245,7 +243,7 @@ async function checkVenvStatus(force = false): Promise<ReadyStatus> {
}
// Check venv exists
if (!existsSync(CODEXLENS_VENV)) {
if (!existsSync(getCodexLensVenvDir())) {
const result = { ready: false, error: 'Venv not found' };
venvStatusCache = { status: result, timestamp: Date.now() };
console.log(`[PERF][CodexLens] checkVenvStatus (no venv): ${Date.now() - funcStart}ms`);
@@ -253,7 +251,7 @@ async function checkVenvStatus(force = false): Promise<ReadyStatus> {
}
// Check python executable exists
if (!existsSync(VENV_PYTHON)) {
if (!existsSync(getCodexLensPython())) {
const result = { ready: false, error: 'Python executable not found in venv' };
venvStatusCache = { status: result, timestamp: Date.now() };
console.log(`[PERF][CodexLens] checkVenvStatus (no python): ${Date.now() - funcStart}ms`);
@@ -265,7 +263,7 @@ async function checkVenvStatus(force = false): Promise<ReadyStatus> {
console.log('[PERF][CodexLens] checkVenvStatus spawning Python...');
return new Promise((resolve) => {
const child = spawn(VENV_PYTHON, ['-c', 'import codexlens; import watchdog; print(codexlens.__version__)'], {
const child = spawn(getCodexLensPython(), ['-c', 'import codexlens; import watchdog; print(codexlens.__version__)'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10000,
});
@@ -377,7 +375,7 @@ try:
except Exception as e:
print(json.dumps({"available": False, "error": str(e)}))
`;
const child = spawn(VENV_PYTHON, ['-c', checkCode], {
const child = spawn(getCodexLensPython(), ['-c', checkCode], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 15000,
});
@@ -438,7 +436,7 @@ async function ensureLiteLLMEmbedderReady(): Promise<BootstrapResult> {
// Check if ccw_litellm can be imported
const importStatus = await new Promise<{ ok: boolean; error?: string }>((resolve) => {
const child = spawn(VENV_PYTHON, ['-c', 'import ccw_litellm; print("OK")'], {
const child = spawn(getCodexLensPython(), ['-c', 'import ccw_litellm; print("OK")'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 15000,
});
@@ -502,10 +500,7 @@ async function ensureLiteLLMEmbedderReady(): Promise<BootstrapResult> {
}
// Fallback: Use pip for installation
const pipPath =
process.platform === 'win32'
? join(CODEXLENS_VENV, 'Scripts', 'pip.exe')
: join(CODEXLENS_VENV, 'bin', 'pip');
const pipPath = getCodexLensPip();
try {
if (localPath) {
@@ -552,10 +547,7 @@ interface PythonEnvInfo {
* DirectML requires: 64-bit Python, version 3.8-3.12
*/
async function checkPythonEnvForDirectML(): Promise<PythonEnvInfo> {
const pythonPath =
process.platform === 'win32'
? join(CODEXLENS_VENV, 'Scripts', 'python.exe')
: join(CODEXLENS_VENV, 'bin', 'python');
const pythonPath = getCodexLensPython();
if (!existsSync(pythonPath)) {
return { version: '', majorMinor: '', architecture: 0, compatible: false, error: 'Python not found in venv' };
@@ -800,10 +792,7 @@ async function installSemantic(gpuMode: GpuMode = 'cpu'): Promise<BootstrapResul
console.log(`[CodexLens] Python ${pythonEnv.version} (${pythonEnv.architecture}-bit) - DirectML compatible`);
}
const pipPath =
process.platform === 'win32'
? join(CODEXLENS_VENV, 'Scripts', 'pip.exe')
: join(CODEXLENS_VENV, 'bin', 'pip');
const pipPath = getCodexLensPip();
// IMPORTANT: Uninstall all onnxruntime variants first to prevent conflicts
// Having multiple onnxruntime packages causes provider detection issues
@@ -933,16 +922,18 @@ async function bootstrapVenv(): Promise<BootstrapResult> {
// Fall back to pip logic...
// Ensure data directory exists
if (!existsSync(CODEXLENS_DATA_DIR)) {
mkdirSync(CODEXLENS_DATA_DIR, { recursive: true });
const dataDir = getCodexLensDataDir();
const venvDir = getCodexLensVenvDir();
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
// Create venv if not exists
if (!existsSync(CODEXLENS_VENV)) {
if (!existsSync(venvDir)) {
try {
console.log('[CodexLens] Creating virtual environment...');
const pythonCmd = getSystemPython();
execSync(`${pythonCmd} -m venv "${CODEXLENS_VENV}"`, { stdio: 'inherit', timeout: EXEC_TIMEOUTS.PROCESS_SPAWN });
execSync(`${pythonCmd} -m venv "${venvDir}"`, { stdio: 'inherit', timeout: EXEC_TIMEOUTS.PROCESS_SPAWN });
} catch (err) {
return { success: false, error: `Failed to create venv: ${(err as Error).message}` };
}
@@ -951,10 +942,7 @@ async function bootstrapVenv(): Promise<BootstrapResult> {
// Install codex-lens
try {
console.log('[CodexLens] Installing codex-lens package...');
const pipPath =
process.platform === 'win32'
? join(CODEXLENS_VENV, 'Scripts', 'pip.exe')
: join(CODEXLENS_VENV, 'bin', 'pip');
const pipPath = getCodexLensPip();
// Try local path - codex-lens is local-only, not published to PyPI
const codexLensPath = findLocalCodexLensPath();
@@ -1131,7 +1119,7 @@ async function executeCodexLens(args: string[], options: ExecuteOptions = {}): P
// spawn's cwd option handles drive changes correctly on Windows
const spawnArgs = ['-m', 'codexlens', ...args];
const child = spawn(VENV_PYTHON, spawnArgs, {
const child = spawn(getCodexLensPython(), spawnArgs, {
cwd,
shell: false, // CRITICAL: Prevent command injection
timeout,
@@ -1674,7 +1662,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
async function uninstallCodexLens(): Promise<BootstrapResult> {
try {
// Check if venv exists
if (!existsSync(CODEXLENS_VENV)) {
if (!existsSync(getCodexLensVenvDir())) {
return { success: false, error: 'CodexLens not installed (venv not found)' };
}
@@ -1694,7 +1682,8 @@ async function uninstallCodexLens(): Promise<BootstrapResult> {
await new Promise(resolve => setTimeout(resolve, 500));
}
console.log(`[CodexLens] Removing directory: ${CODEXLENS_DATA_DIR}`);
const dataDir = getCodexLensDataDir();
console.log(`[CodexLens] Removing directory: ${dataDir}`);
// Remove the entire .codexlens directory with retry logic for locked files
const fs = await import('fs');
@@ -1729,7 +1718,7 @@ async function uninstallCodexLens(): Promise<BootstrapResult> {
}
};
await removeWithRetry(CODEXLENS_DATA_DIR);
await removeWithRetry(dataDir);
// Reset bootstrap cache
bootstrapChecked = false;
@@ -1827,7 +1816,7 @@ export {
// Export Python path for direct spawn usage (e.g., watcher)
export function getVenvPythonPath(): string {
return VENV_PYTHON;
return getCodexLensPython();
}
export type { GpuMode, PythonEnvInfo };

View File

@@ -12,7 +12,7 @@
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { getCodexLensPython, getCodexLensVenvDir } from '../utils/codexlens-path.js';
export interface LiteLLMConfig {
pythonPath?: string; // Default: CodexLens venv Python
@@ -22,7 +22,7 @@ export interface LiteLLMConfig {
// Platform-specific constants for CodexLens venv
const IS_WINDOWS = process.platform === 'win32';
const CODEXLENS_VENV = join(homedir(), '.codexlens', 'venv');
const CODEXLENS_VENV = getCodexLensVenvDir();
const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin';
const PYTHON_EXECUTABLE = IS_WINDOWS ? 'python.exe' : 'python';
@@ -40,6 +40,20 @@ export function getCodexLensVenvPython(): string {
return 'python';
}
/**
* Get the Python path from CodexLens venv using centralized path utility
* Falls back to system 'python' if venv doesn't exist
* @returns Path to Python executable
*/
export function getCodexLensPythonPath(): string {
const codexLensPython = getCodexLensPython();
if (existsSync(codexLensPython)) {
return codexLensPython;
}
// Fallback to system Python if venv not available
return 'python';
}
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;

View File

@@ -22,7 +22,18 @@ export interface LoopTask {
/** Task description (what to do) */
description: string;
/** CLI tool to use (bash, builtin tools, cli-wrapper, api-endpoint) */
/**
* CLI tool to use
*
* Should be one of the enabled tools from cli-tools.json:
* - 'bash' (always available)
* - Builtin tools: 'gemini', 'qwen', 'codex', 'claude', 'opencode'
* - CLI wrappers: 'doubao', etc. (if enabled)
* - API endpoints: custom tools (if enabled)
*
* Note: Validation is performed at the API layer (loop-v2-routes.ts)
* to ensure tool is enabled before saving.
*/
tool: string;
/** Execution mode */

View File

@@ -0,0 +1,60 @@
/**
* CodexLens Path Utilities
*
* Provides centralized path resolution for CodexLens data directory,
* respecting the CODEXLENS_DATA_DIR environment variable.
*
* Priority order (matching Python implementation):
* 1. CODEXLENS_DATA_DIR environment variable
* 2. Default: ~/.codexlens
*/
import { join } from 'path';
import { homedir } from 'os';
/**
* Get the CodexLens data directory.
* Respects CODEXLENS_DATA_DIR environment variable.
*
* @returns Path to CodexLens data directory
*/
export function getCodexLensDataDir(): string {
const envOverride = process.env.CODEXLENS_DATA_DIR;
if (envOverride) {
return envOverride;
}
return join(homedir(), '.codexlens');
}
/**
* Get the CodexLens virtual environment path.
*
* @returns Path to CodexLens venv directory
*/
export function getCodexLensVenvDir(): string {
return join(getCodexLensDataDir(), 'venv');
}
/**
* Get the Python executable path in the CodexLens venv.
*
* @returns Path to python executable
*/
export function getCodexLensPython(): string {
const venvDir = getCodexLensVenvDir();
return process.platform === 'win32'
? join(venvDir, 'Scripts', 'python.exe')
: join(venvDir, 'bin', 'python');
}
/**
* Get the pip executable path in the CodexLens venv.
*
* @returns Path to pip executable
*/
export function getCodexLensPip(): string {
const venvDir = getCodexLensVenvDir();
return process.platform === 'win32'
? join(venvDir, 'Scripts', 'pip.exe')
: join(venvDir, 'bin', 'pip');
}

View File

@@ -14,6 +14,7 @@ import { existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir, platform, arch } from 'os';
import { EXEC_TIMEOUTS } from './exec-constants.js';
import { getCodexLensDataDir, getCodexLensVenvDir } from './codexlens-path.js';
/**
* Configuration for UvManager
@@ -767,9 +768,9 @@ export class UvManager {
* @returns Configured UvManager instance
*/
export function createCodexLensUvManager(dataDir?: string): UvManager {
const baseDir = dataDir ?? join(homedir(), '.codexlens');
const baseDir = dataDir ?? getCodexLensDataDir();
return new UvManager({
venvPath: join(baseDir, 'venv'),
venvPath: getCodexLensVenvDir(),
pythonVersion: '>=3.10,<3.13', // onnxruntime compatibility
});
}