Merge branch 'main' into perf/parallel-file-tree

This commit is contained in:
Haile
2026-05-04 13:04:07 +03:00
committed by GitHub
291 changed files with 24419 additions and 12768 deletions

View File

@@ -24,9 +24,9 @@ import {
notifyRunStopped,
notifyUserIfEnabled
} from './services/notification-orchestrator.js';
import { claudeAdapter } from './providers/claude/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createNormalizedMessage } from './shared/utils.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
@@ -149,9 +149,15 @@ function mapCliOptionsToSDK(options = {}) {
const sdkOptions = {};
if (process.env.CLAUDE_CLI_PATH) {
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH;
}
// Forward all host env vars (e.g. ANTHROPIC_BASE_URL) to the subprocess.
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
sdkOptions.env = { ...process.env };
// Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to 'claude' on PATH.
// The SDK 0.2.113+ looks for a bundled native binary optional dep by default;
// this fallback ensures users who installed via the official installer still work
// even when npm prune --production has removed those optional deps.
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
// Map working directory
if (cwd) {
@@ -521,6 +527,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
}]
};
// Caveat: in 'auto' and 'bypassPermissions' modes the SDK resolves approval
// at the permission-mode step and skips this callback, so interactive tools
// (AskUserQuestion, ExitPlanMode) won't reach the UI — the classifier/bypass
// auto-approves them and the model acts on a generated answer. Move these
// tools to a PreToolUse hook (runs before the mode check) if we need them
// to work in those modes.
sdkOptions.canUseTool = async (toolName, input, context) => {
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
@@ -654,7 +666,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
const sid = capturedSessionId || sessionId || null;
// Use adapter to normalize SDK events into NormalizedMessage[]
const normalized = claudeAdapter.normalizeMessage(transformedMessage, sid);
const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
for (const msg of normalized) {
// Preserve parentToolUseId from SDK wrapper for subagent tool grouping
if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
@@ -707,7 +719,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
await cleanupTempFiles(tempImagePaths, tempDir);
// Check if Claude CLI is installed for a clearer error message
const installed = getStatusChecker('claude')?.checkInstalled() ?? true;
const installed = await providerAuthService.isProviderInstalled('claude');
const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message;

View File

@@ -1,9 +1,9 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { cursorAdapter } from './providers/cursor/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -190,7 +190,7 @@ async function spawnCursor(command, options = {}, ws) {
case 'assistant':
// Accumulate assistant message chunks
if (response.message && response.message.content && response.message.content.length > 0) {
const normalized = cursorAdapter.normalizeMessage(response, capturedSessionId || sessionId || null);
const normalized = sessionsService.normalizeMessage('cursor', response, capturedSessionId || sessionId || null);
for (const msg of normalized) ws.send(msg);
}
break;
@@ -220,7 +220,7 @@ async function spawnCursor(command, options = {}, ws) {
}
// If not JSON, send as stream delta via adapter
const normalized = cursorAdapter.normalizeMessage(line, capturedSessionId || sessionId || null);
const normalized = sessionsService.normalizeMessage('cursor', line, capturedSessionId || sessionId || null);
for (const msg of normalized) ws.send(msg);
}
};
@@ -288,7 +288,7 @@ async function spawnCursor(command, options = {}, ws) {
});
// Handle process errors
cursorProcess.on('error', (error) => {
cursorProcess.on('error', async (error) => {
console.error('Cursor CLI process error:', error);
// Clean up process reference on error
@@ -296,7 +296,7 @@ async function spawnCursor(command, options = {}, ws) {
activeCursorProcesses.delete(finalSessionId);
// Check if Cursor CLI is installed for a clearer error message
const installed = getStatusChecker('cursor')?.checkInstalled() ?? true;
const installed = await providerAuthService.isProviderInstalled('cursor');
const errorContent = !installed
? 'Cursor CLI is not installed. Please install it from https://cursor.com'
: error.message;

View File

@@ -1,593 +0,0 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
import {
APP_CONFIG_TABLE_SQL,
USER_NOTIFICATION_PREFERENCES_TABLE_SQL,
VAPID_KEYS_TABLE_SQL,
PUSH_SUBSCRIPTIONS_TABLE_SQL,
SESSION_NAMES_TABLE_SQL,
SESSION_NAMES_LOOKUP_INDEX_SQL,
DATABASE_SCHEMA_SQL
} from './schema.js';
const __dirname = getModuleDir(import.meta.url);
// The compiled backend lives under dist-server/server/database, but the install root we log
// should still point at the project/app root. Resolving it here avoids build-layout drift.
const APP_ROOT = findAppRoot(__dirname);
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
// Use DATABASE_PATH environment variable if set, otherwise use default location
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
// Ensure database directory exists if custom path is provided
if (process.env.DATABASE_PATH) {
const dbDir = path.dirname(DB_PATH);
try {
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
console.log(`Created database directory: ${dbDir}`);
}
} catch (error) {
console.error(`Failed to create database directory ${dbDir}:`, error.message);
throw error;
}
}
// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location
const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
try {
fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
for (const suffix of ['-wal', '-shm']) {
if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
}
}
} catch (err) {
console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
}
}
// Create database connection
const db = new Database(DB_PATH);
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
// runMigrations() also creates this table, but it runs too late for existing installations
// where auth.js is imported before initializeDatabase() is called.
db.exec(APP_CONFIG_TABLE_SQL);
// Show app installation path prominently
const appInstallPath = APP_ROOT;
console.log('');
console.log(c.dim('═'.repeat(60)));
console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);
if (process.env.DATABASE_PATH) {
console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`);
}
console.log(c.dim('═'.repeat(60)));
console.log('');
const runMigrations = () => {
try {
const tableInfo = db.prepare("PRAGMA table_info(users)").all();
const columnNames = tableInfo.map(col => col.name);
if (!columnNames.includes('git_name')) {
console.log('Running migration: Adding git_name column');
db.exec('ALTER TABLE users ADD COLUMN git_name TEXT');
}
if (!columnNames.includes('git_email')) {
console.log('Running migration: Adding git_email column');
db.exec('ALTER TABLE users ADD COLUMN git_email TEXT');
}
if (!columnNames.includes('has_completed_onboarding')) {
console.log('Running migration: Adding has_completed_onboarding column');
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
}
db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SQL);
db.exec(VAPID_KEYS_TABLE_SQL);
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SQL);
db.exec(APP_CONFIG_TABLE_SQL);
db.exec(SESSION_NAMES_TABLE_SQL);
db.exec(SESSION_NAMES_LOOKUP_INDEX_SQL);
console.log('Database migrations completed successfully');
} catch (error) {
console.error('Error running migrations:', error.message);
throw error;
}
};
// Initialize database with schema
const initializeDatabase = async () => {
try {
db.exec(DATABASE_SCHEMA_SQL);
console.log('Database initialized successfully');
runMigrations();
} catch (error) {
console.error('Error initializing database:', error.message);
throw error;
}
};
// User database operations
const userDb = {
// Check if any users exist
hasUsers: () => {
try {
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
return row.count > 0;
} catch (err) {
throw err;
}
},
// Create a new user
createUser: (username, passwordHash) => {
try {
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
const result = stmt.run(username, passwordHash);
return { id: result.lastInsertRowid, username };
} catch (err) {
throw err;
}
},
// Get user by username
getUserByUsername: (username) => {
try {
const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
return row;
} catch (err) {
throw err;
}
},
// Update last login time (non-fatal — logged but not thrown)
updateLastLogin: (userId) => {
try {
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
} catch (err) {
console.warn('Failed to update last login:', err.message);
}
},
// Get user by ID
getUserById: (userId) => {
try {
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);
return row;
} catch (err) {
throw err;
}
},
getFirstUser: () => {
try {
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get();
return row;
} catch (err) {
throw err;
}
},
updateGitConfig: (userId, gitName, gitEmail) => {
try {
const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?');
stmt.run(gitName, gitEmail, userId);
} catch (err) {
throw err;
}
},
getGitConfig: (userId) => {
try {
const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId);
return row;
} catch (err) {
throw err;
}
},
completeOnboarding: (userId) => {
try {
const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?');
stmt.run(userId);
} catch (err) {
throw err;
}
},
hasCompletedOnboarding: (userId) => {
try {
const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId);
return row?.has_completed_onboarding === 1;
} catch (err) {
throw err;
}
}
};
// API Keys database operations
const apiKeysDb = {
// Generate a new API key
generateApiKey: () => {
return 'ck_' + crypto.randomBytes(32).toString('hex');
},
// Create a new API key
createApiKey: (userId, keyName) => {
try {
const apiKey = apiKeysDb.generateApiKey();
const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)');
const result = stmt.run(userId, keyName, apiKey);
return { id: result.lastInsertRowid, keyName, apiKey };
} catch (err) {
throw err;
}
},
// Get all API keys for a user
getApiKeys: (userId) => {
try {
const rows = db.prepare('SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId);
return rows;
} catch (err) {
throw err;
}
},
// Validate API key and get user
validateApiKey: (apiKey) => {
try {
const row = db.prepare(`
SELECT u.id, u.username, ak.id as api_key_id
FROM api_keys ak
JOIN users u ON ak.user_id = u.id
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1
`).get(apiKey);
if (row) {
// Update last_used timestamp
db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id);
}
return row;
} catch (err) {
throw err;
}
},
// Delete an API key
deleteApiKey: (userId, apiKeyId) => {
try {
const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?');
const result = stmt.run(apiKeyId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
},
// Toggle API key active status
toggleApiKey: (userId, apiKeyId, isActive) => {
try {
const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?');
const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
}
};
// User credentials database operations (for GitHub tokens, GitLab tokens, etc.)
const credentialsDb = {
// Create a new credential
createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => {
try {
const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)');
const result = stmt.run(userId, credentialName, credentialType, credentialValue, description);
return { id: result.lastInsertRowid, credentialName, credentialType };
} catch (err) {
throw err;
}
},
// Get all credentials for a user, optionally filtered by type
getCredentials: (userId, credentialType = null) => {
try {
let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?';
const params = [userId];
if (credentialType) {
query += ' AND credential_type = ?';
params.push(credentialType);
}
query += ' ORDER BY created_at DESC';
const rows = db.prepare(query).all(...params);
return rows;
} catch (err) {
throw err;
}
},
// Get active credential value for a user by type (returns most recent active)
getActiveCredential: (userId, credentialType) => {
try {
const row = db.prepare('SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1').get(userId, credentialType);
return row?.credential_value || null;
} catch (err) {
throw err;
}
},
// Delete a credential
deleteCredential: (userId, credentialId) => {
try {
const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?');
const result = stmt.run(credentialId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
},
// Toggle credential active status
toggleCredential: (userId, credentialId, isActive) => {
try {
const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?');
const result = stmt.run(isActive ? 1 : 0, credentialId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
}
};
const DEFAULT_NOTIFICATION_PREFERENCES = {
channels: {
inApp: false,
webPush: false
},
events: {
actionRequired: true,
stop: true,
error: true
}
};
const normalizeNotificationPreferences = (value) => {
const source = value && typeof value === 'object' ? value : {};
return {
channels: {
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true
},
events: {
actionRequired: source.events?.actionRequired !== false,
stop: source.events?.stop !== false,
error: source.events?.error !== false
}
};
};
const notificationPreferencesDb = {
getPreferences: (userId) => {
try {
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
if (!row) {
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
db.prepare(
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
).run(userId, JSON.stringify(defaults));
return defaults;
}
let parsed;
try {
parsed = JSON.parse(row.preferences_json);
} catch {
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
}
return normalizeNotificationPreferences(parsed);
} catch (err) {
throw err;
}
},
updatePreferences: (userId, preferences) => {
try {
const normalized = normalizeNotificationPreferences(preferences);
db.prepare(
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
preferences_json = excluded.preferences_json,
updated_at = CURRENT_TIMESTAMP`
).run(userId, JSON.stringify(normalized));
return normalized;
} catch (err) {
throw err;
}
}
};
const pushSubscriptionsDb = {
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
try {
db.prepare(
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
keys_p256dh = excluded.keys_p256dh,
keys_auth = excluded.keys_auth`
).run(userId, endpoint, keysP256dh, keysAuth);
} catch (err) {
throw err;
}
},
getSubscriptions: (userId) => {
try {
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
} catch (err) {
throw err;
}
},
removeSubscription: (endpoint) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
} catch (err) {
throw err;
}
},
removeAllForUser: (userId) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
} catch (err) {
throw err;
}
}
};
// Session custom names database operations
const sessionNamesDb = {
// Set (insert or update) a custom session name
setName: (sessionId, provider, customName) => {
db.prepare(`
INSERT INTO session_names (session_id, provider, custom_name)
VALUES (?, ?, ?)
ON CONFLICT(session_id, provider)
DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP
`).run(sessionId, provider, customName);
},
// Get a single custom session name
getName: (sessionId, provider) => {
const row = db.prepare(
'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'
).get(sessionId, provider);
return row?.custom_name || null;
},
// Batch lookup — returns Map<sessionId, customName>
getNames: (sessionIds, provider) => {
if (!sessionIds.length) return new Map();
const placeholders = sessionIds.map(() => '?').join(',');
const rows = db.prepare(
`SELECT session_id, custom_name FROM session_names
WHERE session_id IN (${placeholders}) AND provider = ?`
).all(...sessionIds, provider);
return new Map(rows.map(r => [r.session_id, r.custom_name]));
},
// Delete a custom session name
deleteName: (sessionId, provider) => {
return db.prepare(
'DELETE FROM session_names WHERE session_id = ? AND provider = ?'
).run(sessionId, provider).changes > 0;
},
};
// Apply custom session names from the database (overrides CLI-generated summaries)
function applyCustomSessionNames(sessions, provider) {
if (!sessions?.length) return;
try {
const ids = sessions.map(s => s.id);
const customNames = sessionNamesDb.getNames(ids, provider);
for (const session of sessions) {
const custom = customNames.get(session.id);
if (custom) session.summary = custom;
}
} catch (error) {
console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message);
}
}
// App config database operations
const appConfigDb = {
get: (key) => {
try {
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
return row?.value || null;
} catch (err) {
return null;
}
},
set: (key, value) => {
db.prepare(
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
},
getOrCreateJwtSecret: () => {
let secret = appConfigDb.get('jwt_secret');
if (!secret) {
secret = crypto.randomBytes(64).toString('hex');
appConfigDb.set('jwt_secret', secret);
}
return secret;
}
};
// Backward compatibility - keep old names pointing to new system
const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => {
return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description);
},
getGithubTokens: (userId) => {
return credentialsDb.getCredentials(userId, 'github_token');
},
getActiveGithubToken: (userId) => {
return credentialsDb.getActiveCredential(userId, 'github_token');
},
deleteGithubToken: (userId, tokenId) => {
return credentialsDb.deleteCredential(userId, tokenId);
},
toggleGithubToken: (userId, tokenId, isActive) => {
return credentialsDb.toggleCredential(userId, tokenId, isActive);
}
};
export {
db,
initializeDatabase,
userDb,
apiKeysDb,
credentialsDb,
notificationPreferencesDb,
pushSubscriptionsDb,
sessionNamesDb,
applyCustomSessionNames,
appConfigDb,
githubTokensDb // Backward compatibility
};

View File

@@ -1,102 +0,0 @@
export const APP_CONFIG_TABLE_SQL = `CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`;
export const USER_NOTIFICATION_PREFERENCES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`;
export const VAPID_KEYS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`;
export const PUSH_SUBSCRIPTIONS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`;
export const SESSION_NAMES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
);`;
export const SESSION_NAMES_LOOKUP_INDEX_SQL = `CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);`;
export const DATABASE_SCHEMA_SQL = `PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1,
git_name TEXT,
git_email TEXT,
has_completed_onboarding BOOLEAN DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key_name TEXT NOT NULL,
api_key TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
CREATE TABLE IF NOT EXISTS user_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_name TEXT NOT NULL,
credential_type TEXT NOT NULL,
credential_value TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
${USER_NOTIFICATION_PREFERENCES_TABLE_SQL}
${VAPID_KEYS_TABLE_SQL}
${PUSH_SUBSCRIPTIONS_TABLE_SQL}
${SESSION_NAMES_TABLE_SQL}
${SESSION_NAMES_LOOKUP_INDEX_SQL}
${APP_CONFIG_TABLE_SQL}
`;

View File

@@ -9,8 +9,8 @@ import os from 'os';
import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createNormalizedMessage } from './shared/utils.js';
let activeGeminiProcesses = new Map(); // Track active processes by session ID
@@ -383,7 +383,7 @@ async function spawnGemini(command, options = {}, ws) {
} else {
// code 127 = shell "command not found" — check installation
if (code === 127) {
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
const installed = await providerAuthService.isProviderInstalled('gemini');
if (!installed) {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
@@ -399,13 +399,13 @@ async function spawnGemini(command, options = {}, ws) {
});
// Handle process errors
geminiProcess.on('error', (error) => {
geminiProcess.on('error', async (error) => {
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
// Check if Gemini CLI is installed for a clearer error message
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
const installed = await providerAuthService.isProviderInstalled('gemini');
const errorContent = !installed
? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'
: error.message;

View File

@@ -1,5 +1,5 @@
// Gemini Response Handler - JSON Stream processing
import { geminiAdapter } from './providers/gemini/adapter.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
class GeminiResponseHandler {
constructor(ws, options = {}) {
@@ -56,7 +56,7 @@ class GeminiResponseHandler {
}
// Normalize via adapter and send all resulting messages
const normalized = geminiAdapter.normalizeMessage(event, sid);
const normalized = sessionsService.normalizeMessage('gemini', event, sid);
for (const msg of normalized) {
this.ws.send(msg);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import jwt from 'jsonwebtoken';
import { userDb, appConfigDb } from '../database/db.js';
import { userDb, appConfigDb } from '../modules/database/index.js';
import { IS_PLATFORM } from '../constants/config.js';
// Use env var if set, otherwise auto-generate a unique secret per installation

View File

@@ -0,0 +1,143 @@
/**
* Database connection management.
*
* Owns the single SQLite connection used across all repositories.
* Handles path resolution, directory creation, legacy database migration,
* and eager app_config bootstrap so the auth middleware can read the
* JWT secret before the full schema is applied.
*
* Consumers should never create their own Database instance — they use
* `getConnection()` to obtain the shared singleton.
*/
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { APP_CONFIG_TABLE_SCHEMA_SQL } from '@/modules/database/schema.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
/**
* Resolves the database file path from environment or falls back
* to the legacy location inside the server/database/ folder.
*
* Priority:
* 1. DATABASE_PATH environment variable (set by cli.js or load-env-vars.js)
* 2. Legacy path: server/database/auth.db
*/
function resolveDatabasePath(): string {
// process.env.DATABASE_PATH is set by load-env-vars.js to either the .env value or a default(~/.cloudcli/auth.db) in the user's home directory.
return process.env.DATABASE_PATH || resolveLegacyDatabasePath();
}
/**
* Resolves the legacy database path (always inside server/database/).
* Used for the one-time migration to the new external location.
*/
function resolveLegacyDatabasePath(): string {
const serverDir = path.resolve(__dirname, '..', '..', '..');
return path.join(serverDir, 'database', 'auth.db');
}
// ---------------------------------------------------------------------------
// Directory & migration helpers
// ---------------------------------------------------------------------------
function ensureDatabaseDirectory(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log('Created database directory:', dir);
}
}
/**
* If the database was moved to an external location (e.g. ~/.cloudcli/)
* but the user still has a legacy auth.db inside the install directory,
* copy it to the new location as a one-time migration.
*/
function migrateLegacyDatabase(targetPath: string): void {
const legacyPath = resolveLegacyDatabasePath();
if (targetPath === legacyPath) return;
if (fs.existsSync(targetPath)) return;
if (!fs.existsSync(legacyPath)) return;
try {
fs.copyFileSync(legacyPath, targetPath);
console.log('Migrated legacy database', { from: legacyPath, to: targetPath });
// copy the write-ahead log and shared memory files (auth.db-wal, auth.db-shm) if they exist, to preserve any uncommitted transactions
for (const suffix of ['-wal', '-shm']) {
const src = legacyPath + suffix;
if (fs.existsSync(src)) {
fs.copyFileSync(src, targetPath + suffix);
}
}
} catch (err: any) {
console.error('Could not migrate legacy database', { error: err.message });
}
}
// ---------------------------------------------------------------------------
// Singleton connection
// ---------------------------------------------------------------------------
let instance: Database.Database | null = null;
/**
* Returns the shared database connection, creating it on first call.
*
* The first invocation:
* 1. Resolves the target database path
* 2. Ensures the parent directory exists
* 3. Migrates from the legacy install-directory path if needed
* 4. Opens the SQLite connection
* 5. Eagerly creates the app_config table (auth reads JWT secret at import time)
* 6. Logs the database location
*/
export function getConnection(): Database.Database {
if (instance) return instance;
const dbPath = resolveDatabasePath();
ensureDatabaseDirectory(dbPath);
migrateLegacyDatabase(dbPath);
instance = new Database(dbPath);
// app_config must exist immediately — the auth middleware reads
// the JWT secret at module-load time, before initializeDatabase() runs.
instance.exec(APP_CONFIG_TABLE_SCHEMA_SQL);
return instance;
}
/**
* Returns the resolved database file path without opening a connection.
* Useful for diagnostics and CLI status commands.
*/
export function getDatabasePath(): string {
return resolveDatabasePath();
}
/**
* Closes the database connection and clears the singleton.
* Primarily used for graceful shutdown or testing.
*/
export function closeConnection(): void {
if (instance) {
instance.close();
instance = null;
console.log('Database connection closed');
}
}

View File

@@ -0,0 +1,12 @@
export { initializeDatabase } from '@/modules/database/init-db.js';
export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js';
export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
export { scanStateDb } from '@/modules/database/repositories/scan-state.db.js';
export { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
export { userDb } from '@/modules/database/repositories/users.js';
export { vapidKeysDb } from '@/modules/database/repositories/vapid-keys.js';

View File

@@ -0,0 +1,17 @@
import { getConnection } from "@/modules/database/connection.js";
import { runMigrations } from "@/modules/database/migrations.js";
import { INIT_SCHEMA_SQL } from "@/modules/database/schema.js";
// Initialize database with schema
export const initializeDatabase = async () => {
try {
const db = getConnection();
db.exec(INIT_SCHEMA_SQL);
console.log('Database schema applied');
runMigrations(db);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.log('Database initialization failed', { error: message });
throw err;
}
};

View File

@@ -0,0 +1,443 @@
import { Database } from 'better-sqlite3';
import {
APP_CONFIG_TABLE_SCHEMA_SQL,
LAST_SCANNED_AT_SQL,
PROJECTS_TABLE_SCHEMA_SQL,
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
SESSIONS_TABLE_SCHEMA_SQL,
USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL,
VAPID_KEYS_TABLE_SCHEMA_SQL,
} from '@/modules/database/schema.js';
const SQLITE_UUID_SQL = `
lower(hex(randomblob(4))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
lower(hex(randomblob(6)))
`;
type TableInfoRow = {
name: string;
pk: number;
};
const addColumnToTableIfNotExists = (
db: Database,
tableName: string,
columnNames: string[],
columnName: string,
columnType: string
) => {
if (!columnNames.includes(columnName)) {
console.log(`Running migration: Adding ${columnName} column to ${tableName} table`);
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType}`);
}
};
const tableExists = (db: Database, tableName: string): boolean =>
Boolean(
db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName)
);
const getTableInfo = (db: Database, tableName: string): TableInfoRow[] =>
db.prepare(`PRAGMA table_info(${tableName})`).all() as TableInfoRow[];
const migrateLegacySessionNames = (db: Database): void => {
const hasLegacySessionNamesTable = tableExists(db, 'session_names');
const hasSessionsTable = tableExists(db, 'sessions');
if (!hasLegacySessionNamesTable) {
return;
}
if (hasSessionsTable) {
console.log('Running migration: Merging session_names into sessions');
db.exec(`
INSERT INTO sessions (session_id, provider, custom_name, created_at, updated_at)
SELECT
session_id,
COALESCE(provider, 'claude'),
custom_name,
COALESCE(created_at, CURRENT_TIMESTAMP),
COALESCE(updated_at, CURRENT_TIMESTAMP)
FROM session_names
WHERE true
ON CONFLICT(session_id) DO UPDATE SET
provider = excluded.provider,
custom_name = COALESCE(excluded.custom_name, sessions.custom_name),
created_at = COALESCE(sessions.created_at, excluded.created_at),
updated_at = COALESCE(excluded.updated_at, sessions.updated_at)
`);
db.exec('DROP TABLE session_names');
return;
}
console.log('Running migration: Renaming session_names table to sessions');
db.exec('ALTER TABLE session_names RENAME TO sessions');
};
const migrateLegacyWorkspaceTableIntoProjects = (db: Database): void => {
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
if (!tableExists(db, 'workspace_original_paths')) {
return;
}
console.log('Running migration: Migrating workspace_original_paths data into projects');
db.exec(`
INSERT INTO projects (project_id, project_path, custom_project_name, isStarred, isArchived)
SELECT
CASE
WHEN workspace_id IS NULL OR trim(workspace_id) = ''
THEN ${SQLITE_UUID_SQL}
ELSE workspace_id
END,
workspace_path,
custom_workspace_name,
COALESCE(isStarred, 0),
0
FROM workspace_original_paths
WHERE workspace_path IS NOT NULL AND trim(workspace_path) <> ''
ON CONFLICT(project_path) DO UPDATE SET
custom_project_name = COALESCE(projects.custom_project_name, excluded.custom_project_name),
isStarred = COALESCE(projects.isStarred, excluded.isStarred)
`);
};
const rebuildProjectsTableWithPrimaryKeySchema = (db: Database): void => {
const hasProjectsTable = tableExists(db, 'projects');
if (!hasProjectsTable) {
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
return;
}
const projectsTableInfo = getTableInfo(db, 'projects');
const columnNames = projectsTableInfo.map((column) => column.name);
const hasProjectIdPrimaryKey = projectsTableInfo.some(
(column) => column.name === 'project_id' && column.pk === 1,
);
if (hasProjectIdPrimaryKey) {
addColumnToTableIfNotExists(db, 'projects', columnNames, 'custom_project_name', 'TEXT DEFAULT NULL');
addColumnToTableIfNotExists(db, 'projects', columnNames, 'isStarred', 'BOOLEAN DEFAULT 0');
addColumnToTableIfNotExists(db, 'projects', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0');
db.exec(`
UPDATE projects
SET project_id = ${SQLITE_UUID_SQL}
WHERE project_id IS NULL OR trim(project_id) = ''
`);
return;
}
console.log('Running migration: Rebuilding projects table to enforce project_id primary key');
const projectPathExpression = columnNames.includes('project_path')
? 'project_path'
: columnNames.includes('workspace_path')
? 'workspace_path'
: 'NULL';
const customProjectNameExpression = columnNames.includes('custom_project_name')
? 'custom_project_name'
: columnNames.includes('custom_workspace_name')
? 'custom_workspace_name'
: 'NULL';
const isStarredExpression = columnNames.includes('isStarred') ? 'COALESCE(isStarred, 0)' : '0';
const isArchivedExpression = columnNames.includes('isArchived') ? 'COALESCE(isArchived, 0)' : '0';
const projectIdExpression = columnNames.includes('project_id')
? `CASE
WHEN project_id IS NULL OR trim(project_id) = ''
THEN ${SQLITE_UUID_SQL}
ELSE project_id
END`
: SQLITE_UUID_SQL;
db.exec('PRAGMA foreign_keys = OFF');
try {
db.exec('BEGIN TRANSACTION');
db.exec('DROP TABLE IF EXISTS projects__new');
db.exec(`
CREATE TABLE projects__new (
project_id TEXT PRIMARY KEY NOT NULL,
project_path TEXT NOT NULL UNIQUE,
custom_project_name TEXT DEFAULT NULL,
isStarred BOOLEAN DEFAULT 0,
isArchived BOOLEAN DEFAULT 0
)
`);
db.exec(`
WITH source_rows AS (
SELECT
${projectPathExpression} AS project_path,
${customProjectNameExpression} AS custom_project_name,
${isStarredExpression} AS isStarred,
${isArchivedExpression} AS isArchived,
${projectIdExpression} AS candidate_project_id,
rowid AS source_rowid
FROM projects
WHERE ${projectPathExpression} IS NOT NULL AND trim(${projectPathExpression}) <> ''
),
deduped_paths AS (
SELECT
project_path,
custom_project_name,
isStarred,
isArchived,
candidate_project_id,
source_rowid,
ROW_NUMBER() OVER (PARTITION BY project_path ORDER BY source_rowid) AS project_path_rank
FROM source_rows
),
prepared_rows AS (
SELECT
CASE
WHEN ROW_NUMBER() OVER (PARTITION BY candidate_project_id ORDER BY source_rowid) = 1
THEN candidate_project_id
ELSE ${SQLITE_UUID_SQL}
END AS project_id,
project_path,
custom_project_name,
isStarred,
isArchived
FROM deduped_paths
WHERE project_path_rank = 1
)
INSERT INTO projects__new (
project_id,
project_path,
custom_project_name,
isStarred,
isArchived
)
SELECT
project_id,
project_path,
custom_project_name,
isStarred,
isArchived
FROM prepared_rows
`);
db.exec('DROP TABLE projects');
db.exec('ALTER TABLE projects__new RENAME TO projects');
db.exec('COMMIT');
} catch (migrationError) {
db.exec('ROLLBACK');
throw migrationError;
} finally {
db.exec('PRAGMA foreign_keys = ON');
}
};
const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
const hasSessions = tableExists(db, 'sessions');
if (!hasSessions) {
db.exec(SESSIONS_TABLE_SCHEMA_SQL);
return;
}
const sessionsTableInfo = getTableInfo(db, 'sessions');
const columnNames = sessionsTableInfo.map((column) => column.name);
const primaryKeyColumns = sessionsTableInfo
.filter((column) => column.pk > 0)
.sort((a, b) => a.pk - b.pk)
.map((column) => column.name);
const shouldRebuild =
!columnNames.includes('project_path') ||
primaryKeyColumns.length !== 1 ||
primaryKeyColumns[0] !== 'session_id' ||
!columnNames.includes('provider');
if (!shouldRebuild) {
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'jsonl_path', 'TEXT');
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'created_at', 'DATETIME');
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'updated_at', 'DATETIME');
db.exec('UPDATE sessions SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)');
db.exec('UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)');
return;
}
console.log('Running migration: Rebuilding sessions table to project-based schema');
const projectPathExpression = columnNames.includes('project_path')
? 'project_path'
: columnNames.includes('workspace_path')
? 'workspace_path'
: 'NULL';
const providerExpression = columnNames.includes('provider')
? "COALESCE(provider, 'claude')"
: "'claude'";
const customNameExpression = columnNames.includes('custom_name')
? 'custom_name'
: 'NULL';
const jsonlPathExpression = columnNames.includes('jsonl_path')
? 'jsonl_path'
: 'NULL';
const createdAtExpression = columnNames.includes('created_at')
? 'COALESCE(created_at, CURRENT_TIMESTAMP)'
: 'CURRENT_TIMESTAMP';
const updatedAtExpression = columnNames.includes('updated_at')
? 'COALESCE(updated_at, CURRENT_TIMESTAMP)'
: 'CURRENT_TIMESTAMP';
db.exec('PRAGMA foreign_keys = OFF');
try {
db.exec('BEGIN TRANSACTION');
db.exec('DROP TABLE IF EXISTS sessions__new');
db.exec(`
CREATE TABLE sessions__new (
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT,
project_path TEXT,
jsonl_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (session_id),
FOREIGN KEY (project_path) REFERENCES projects(project_path)
ON DELETE SET NULL
ON UPDATE CASCADE
)
`);
db.exec(`
WITH source_rows AS (
SELECT
session_id,
${providerExpression} AS provider,
${customNameExpression} AS custom_name,
${projectPathExpression} AS project_path,
${jsonlPathExpression} AS jsonl_path,
${createdAtExpression} AS created_at,
${updatedAtExpression} AS updated_at,
rowid AS source_rowid
FROM sessions
WHERE session_id IS NOT NULL AND trim(session_id) <> ''
),
ranked_rows AS (
SELECT
session_id,
provider,
custom_name,
project_path,
jsonl_path,
created_at,
updated_at,
ROW_NUMBER() OVER (
PARTITION BY session_id
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, source_rowid DESC
) AS session_rank
FROM source_rows
)
INSERT INTO sessions__new (
session_id,
provider,
custom_name,
project_path,
jsonl_path,
created_at,
updated_at
)
SELECT
session_id,
provider,
custom_name,
project_path,
jsonl_path,
created_at,
updated_at
FROM ranked_rows
WHERE session_rank = 1
`);
db.exec('DROP TABLE sessions');
db.exec('ALTER TABLE sessions__new RENAME TO sessions');
db.exec('COMMIT');
} catch (migrationError) {
db.exec('ROLLBACK');
throw migrationError;
} finally {
db.exec('PRAGMA foreign_keys = ON');
}
};
const ensureProjectsForSessionPaths = (db: Database): void => {
if (!tableExists(db, 'sessions')) {
return;
}
db.exec(`
INSERT INTO projects (project_id, project_path, custom_project_name, isStarred, isArchived)
SELECT
${SQLITE_UUID_SQL},
project_path,
NULL,
0,
0
FROM sessions
WHERE project_path IS NOT NULL AND trim(project_path) <> ''
ON CONFLICT(project_path) DO NOTHING
`);
};
export const runMigrations = (db: Database) => {
try {
const usersTableInfo = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
const userColumnNames = usersTableInfo.map((column) => column.name);
addColumnToTableIfNotExists(db, 'users', userColumnNames, 'git_name', 'TEXT');
addColumnToTableIfNotExists(db, 'users', userColumnNames, 'git_email', 'TEXT');
addColumnToTableIfNotExists(
db,
'users',
userColumnNames,
'has_completed_onboarding',
'BOOLEAN DEFAULT 0'
);
db.exec(APP_CONFIG_TABLE_SCHEMA_SQL);
db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL);
db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)');
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
rebuildProjectsTableWithPrimaryKeySchema(db);
migrateLegacyWorkspaceTableIntoProjects(db);
rebuildSessionsTableWithProjectSchema(db);
migrateLegacySessionNames(db);
ensureProjectsForSessionPaths(db);
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON projects(isArchived)');
db.exec('DROP INDEX IF EXISTS idx_session_names_lookup');
db.exec('DROP INDEX IF EXISTS idx_sessions_workspace_path');
db.exec('DROP INDEX IF EXISTS idx_workspace_original_paths_is_starred');
db.exec('DROP INDEX IF EXISTS idx_workspace_original_paths_workspace_id');
if (tableExists(db, 'workspace_original_paths')) {
console.log('Running migration: Dropping legacy workspace_original_paths table');
db.exec('DROP TABLE workspace_original_paths');
}
db.exec(LAST_SCANNED_AT_SQL);
console.log('Database migrations completed successfully');
} catch (error: any) {
console.error('Error running migrations:', error.message);
throw error;
}
};

View File

@@ -0,0 +1,119 @@
/**
* API keys repository.
*
* Manages API keys used for external/programmatic access to the backend.
* Keys are prefixed with `ck_` and tied to a user via foreign key.
*/
import crypto from 'crypto';
import { getConnection } from '@/modules/database/connection.js';
type ApiKeyRow = {
id: number;
key_name: string;
api_key: string;
created_at: string;
last_used: string | null;
is_active: number;
};
type CreateApiKeyResult = {
id: number | bigint;
keyName: string;
apiKey: string;
};
type ValidatedApiKeyUser = {
id: number;
username: string;
api_key_id: number;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Generates a cryptographically random API key with the `ck_` prefix. */
function generateApiKey(): string {
return 'ck_' + crypto.randomBytes(32).toString('hex');
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export const apiKeysDb = {
generateApiKey,
/** Creates a new API key for the given user and returns it for one-time display. */
createApiKey(userId: number, keyName: string): CreateApiKeyResult {
const db = getConnection();
const apiKey = generateApiKey();
const result = db
.prepare(
'INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)'
)
.run(userId, keyName, apiKey);
return { id: result.lastInsertRowid, keyName, apiKey };
},
/** Lists all API keys for a user, most recent first. */
getApiKeys(userId: number): ApiKeyRow[] {
const db = getConnection();
return db
.prepare(
'SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC'
)
.all(userId) as ApiKeyRow[];
},
/**
* Validates an API key and resolves the owning user.
* If the key is valid, its `last_used` timestamp is updated as a side effect.
* Returns undefined when the key is invalid or the user is inactive.
*/
validateApiKey(apiKey: string): ValidatedApiKeyUser | undefined {
const db = getConnection();
const row = db
.prepare(
`SELECT u.id, u.username, ak.id as api_key_id
FROM api_keys ak
JOIN users u ON ak.user_id = u.id
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1`
)
.get(apiKey) as ValidatedApiKeyUser | undefined;
if (row) {
db.prepare(
'UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?'
).run(row.api_key_id);
}
return row;
},
/** Permanently removes an API key. Returns true if a row was deleted. */
deleteApiKey(userId: number, apiKeyId: number): boolean {
const db = getConnection();
const result = db
.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?')
.run(apiKeyId, userId);
return result.changes > 0;
},
/** Enables or disables an API key without deleting it. */
toggleApiKey(
userId: number,
apiKeyId: number,
isActive: boolean
): boolean {
const db = getConnection();
const result = db
.prepare(
'UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?'
)
.run(isActive ? 1 : 0, apiKeyId, userId);
return result.changes > 0;
},
};

View File

@@ -0,0 +1,53 @@
/**
* App config repository.
*
* Key-value store for application-level configuration that persists
* across restarts (JWT secret, feature flags, etc.). Values are always
* stored as strings; callers handle parsing.
*/
import crypto from 'crypto';
import { getConnection } from '@/modules/database/connection.js';
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export const appConfigDb = {
/** Returns the stored value for a config key, or null if missing. */
get(key: string): string | null {
try {
const db = getConnection();
const row = db
.prepare('SELECT value FROM app_config WHERE key = ?')
.get(key) as { value: string } | undefined;
return row?.value ?? null;
} catch {
// Swallow errors so early-startup reads (e.g. JWT secret) do not crash.
return null;
}
},
/** Inserts or updates a config key (upsert). */
set(key: string, value: string): void {
const db = getConnection();
db.prepare(
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
},
/**
* Returns the JWT signing secret, generating and persisting one
* if it does not already exist. This ensures the secret survives
* server restarts while being created automatically on first boot.
*/
getOrCreateJwtSecret(): string {
let secret = appConfigDb.get('jwt_secret');
if (!secret) {
secret = crypto.randomBytes(64).toString('hex');
appConfigDb.set('jwt_secret', secret);
}
return secret;
},
};

View File

@@ -0,0 +1,106 @@
/**
* User credentials repository.
*
* Manages external service tokens (GitHub, GitLab, Bitbucket, etc.)
* stored per-user. Each credential has a type discriminator so multiple
* credential kinds can coexist in the same table.
*/
import { getConnection } from '@/modules/database/connection.js';
import type {
CreateCredentialResult,
CredentialPublicRow,
} from '@/shared/types.js';
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export const credentialsDb = {
/** Stores a new credential and returns a safe (no raw value) result. */
createCredential(
userId: number,
credentialName: string,
credentialType: string,
credentialValue: string,
description: string | null = null
): CreateCredentialResult {
const db = getConnection();
const result = db
.prepare(
'INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)'
)
.run(userId, credentialName, credentialType, credentialValue, description);
return {
id: result.lastInsertRowid,
credentialName,
credentialType,
};
},
/**
* Lists credentials for a user (excluding raw values).
* Optionally filters by credential type (e.g. 'github_token').
*/
getCredentials(
userId: number,
credentialType: string | null = null
): CredentialPublicRow[] {
const db = getConnection();
if (credentialType) {
return db
.prepare(
'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ? AND credential_type = ? ORDER BY created_at DESC'
)
.all(userId, credentialType) as CredentialPublicRow[];
}
return db
.prepare(
'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ? ORDER BY created_at DESC'
)
.all(userId) as CredentialPublicRow[];
},
/**
* Returns the raw credential value for the most recent active
* credential of the given type, or null if none exists.
*/
getActiveCredential(
userId: number,
credentialType: string
): string | null {
const db = getConnection();
const row = db
.prepare(
'SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1'
)
.get(userId, credentialType) as { credential_value: string } | undefined;
return row?.credential_value ?? null;
},
/** Permanently removes a credential. Returns true if a row was deleted. */
deleteCredential(userId: number, credentialId: number): boolean {
const db = getConnection();
const result = db
.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?')
.run(credentialId, userId);
return result.changes > 0;
},
/** Enables or disables a credential without deleting it. */
toggleCredential(
userId: number,
credentialId: number,
isActive: boolean
): boolean {
const db = getConnection();
const result = db
.prepare(
'UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?'
)
.run(isActive ? 1 : 0, credentialId, userId);
return result.changes > 0;
},
};

View File

@@ -0,0 +1,100 @@
/**
* GitHub tokens repository.
*
* Backward-compatible helper layer over generic credentials storage.
* Tokens are stored in `user_credentials` with `credential_type = 'github_token'`.
*/
import { getConnection } from '@/modules/database/connection.js';
import { credentialsDb } from '@/modules/database/repositories/credentials.js';
import type {
CredentialPublicRow,
CreateCredentialResult,
} from '@/shared/types.js';
const GITHUB_TOKEN_TYPE = 'github_token';
type CredentialRow = {
id: number;
user_id: number;
credential_name: string;
credential_type: string;
credential_value: string;
description: string | null;
created_at: string;
is_active: number;
};
type GithubTokenLookup = CredentialRow & {
github_token: string;
};
export const githubTokensDb = {
/** Creates a GitHub token credential entry. */
createGithubToken(
userId: number,
tokenName: string,
githubToken: string,
description: string | null = null
): CreateCredentialResult {
return credentialsDb.createCredential(
userId,
tokenName,
GITHUB_TOKEN_TYPE,
githubToken,
description
);
},
/** Returns all GitHub tokens (safe shape: no credential value). */
getGithubTokens(userId: number): CredentialPublicRow[] {
return credentialsDb.getCredentials(userId, GITHUB_TOKEN_TYPE);
},
/** Returns the most recent active GitHub token value for a user. */
getActiveGithubToken(userId: number): string | null {
return credentialsDb.getActiveCredential(userId, GITHUB_TOKEN_TYPE);
},
/**
* Returns a specific active GitHub token row by id/user, including
* a `github_token` compatibility field.
*/
getGithubTokenById(userId: number, tokenId: number): GithubTokenLookup | null {
const db = getConnection();
const row = db
.prepare(
`SELECT *
FROM user_credentials
WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1`
)
.get(tokenId, userId, GITHUB_TOKEN_TYPE) as CredentialRow | undefined;
if (!row) return null;
return {
...row,
github_token: row.credential_value,
};
},
/** Updates active state for a GitHub token. */
updateGithubToken(
userId: number,
tokenId: number,
isActive: boolean
): boolean {
return credentialsDb.toggleCredential(userId, tokenId, isActive);
},
/** Deletes a GitHub token. */
deleteGithubToken(userId: number, tokenId: number): boolean {
return credentialsDb.deleteCredential(userId, tokenId);
},
// Legacy alias used by existing routes
toggleGithubToken(userId: number, tokenId: number, isActive: boolean): boolean {
return githubTokensDb.updateGithubToken(userId, tokenId, isActive);
},
};

View File

@@ -0,0 +1,103 @@
/**
* Notification preferences repository.
*
* Stores per-user notification channel/event preferences as JSON.
*/
import { getConnection } from '@/modules/database/connection.js';
type NotificationPreferences = {
channels: {
inApp: boolean;
webPush: boolean;
};
events: {
actionRequired: boolean;
stop: boolean;
error: boolean;
};
};
const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
channels: {
inApp: false,
webPush: false,
},
events: {
actionRequired: true,
stop: true,
error: true,
},
};
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
return {
channels: {
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true,
},
events: {
actionRequired: source.events?.actionRequired !== false,
stop: source.events?.stop !== false,
error: source.events?.error !== false,
},
};
}
export const notificationPreferencesDb = {
/** Returns the normalized preferences for a user, creating defaults on first read. */
getNotificationPreferences(userId: number): NotificationPreferences {
const db = getConnection();
const row = db
.prepare(
'SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?'
)
.get(userId) as { preferences_json: string } | undefined;
if (!row) {
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
db.prepare(
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
).run(userId, JSON.stringify(defaults));
return defaults;
}
let parsed: unknown;
try {
parsed = JSON.parse(row.preferences_json);
} catch {
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
}
return normalizeNotificationPreferences(parsed);
},
/** Upserts normalized preferences for a user and returns the stored value. */
updateNotificationPreferences(
userId: number,
preferences: unknown
): NotificationPreferences {
const normalized = normalizeNotificationPreferences(preferences);
const db = getConnection();
db.prepare(
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
preferences_json = excluded.preferences_json,
updated_at = CURRENT_TIMESTAMP`
).run(userId, JSON.stringify(normalized));
return normalized;
},
// Legacy aliases used by existing services/routes
getPreferences(userId: number): NotificationPreferences {
return notificationPreferencesDb.getNotificationPreferences(userId);
},
updatePreferences(userId: number, preferences: unknown): NotificationPreferences {
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
},
};

View File

@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { closeConnection } from '@/modules/database/connection.js';
import { initializeDatabase } from '@/modules/database/init-db.js';
import { projectsDb } from '@/modules/database/repositories/projects.db.js';
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
const previousDatabasePath = process.env.DATABASE_PATH;
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'projects-db-'));
const databasePath = path.join(tempDirectory, 'auth.db');
closeConnection();
process.env.DATABASE_PATH = databasePath;
await initializeDatabase();
try {
await runTest();
} finally {
closeConnection();
if (previousDatabasePath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = previousDatabasePath;
}
await rm(tempDirectory, { recursive: true, force: true });
}
}
test('projectsDb.createProjectPath returns created for fresh paths', async () => {
await withIsolatedDatabase(() => {
const created = projectsDb.createProjectPath('/workspace/new-project');
assert.equal(created.outcome, 'created');
assert.ok(created.project);
assert.equal(created.project?.project_path, '/workspace/new-project');
assert.equal(created.project?.isArchived, 0);
});
});
test('projectsDb.createProjectPath returns reactivated_archived for archived duplicates', async () => {
await withIsolatedDatabase(() => {
const initial = projectsDb.createProjectPath('/workspace/archived-project', 'Archived Project');
assert.equal(initial.outcome, 'created');
assert.ok(initial.project);
projectsDb.updateProjectIsArchived('/workspace/archived-project', true);
const reused = projectsDb.createProjectPath('/workspace/archived-project', 'Renamed Project');
assert.equal(reused.outcome, 'reactivated_archived');
assert.ok(reused.project);
assert.equal(reused.project?.project_id, initial.project?.project_id);
assert.equal(reused.project?.isArchived, 0);
});
});
test('projectsDb.createProjectPath returns active_conflict for active duplicates', async () => {
await withIsolatedDatabase(() => {
const initial = projectsDb.createProjectPath('/workspace/active-project');
assert.equal(initial.outcome, 'created');
assert.ok(initial.project);
const conflict = projectsDb.createProjectPath('/workspace/active-project');
assert.equal(conflict.outcome, 'active_conflict');
assert.ok(conflict.project);
assert.equal(conflict.project?.project_id, initial.project?.project_id);
assert.equal(conflict.project?.isArchived, 0);
});
});

View File

@@ -0,0 +1,183 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { getConnection } from '@/modules/database/connection.js';
import type { CreateProjectPathResult, ProjectRepositoryRow } from '@/shared/types.js';
import { normalizeProjectPath } from '@/shared/utils.js';
function normalizeProjectDisplayName(projectPath: string, customProjectName: string | null): string {
const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : '';
if (trimmedCustomName.length > 0) {
return trimmedCustomName;
}
const directoryName = path.basename(projectPath);
return directoryName || projectPath;
}
export const projectsDb = {
createProjectPath(projectPath: string, customProjectName: string | null = null): CreateProjectPathResult {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const normalizedProjectName = normalizeProjectDisplayName(normalizedProjectPath, customProjectName);
const attemptedId = randomUUID();
const row = db.prepare(`
INSERT INTO projects (project_id, project_path, custom_project_name, isArchived)
VALUES (?, ?, ?, 0)
ON CONFLICT(project_path) DO UPDATE SET
isArchived = 0
WHERE projects.isArchived = 1
RETURNING project_id, project_path, custom_project_name, isStarred, isArchived
`).get(attemptedId, normalizedProjectPath, normalizedProjectName) as ProjectRepositoryRow | undefined;
if (row) {
return {
outcome: row.project_id === attemptedId ? 'created' : 'reactivated_archived',
project: row,
};
}
const existingProject = projectsDb.getProjectPath(normalizedProjectPath);
return {
outcome: 'active_conflict',
project: existingProject,
};
},
getProjectPath(projectPath: string): ProjectRepositoryRow | null {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const row = db.prepare(`
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
FROM projects
WHERE project_path = ?
`).get(normalizedProjectPath) as ProjectRepositoryRow | undefined;
return row ?? null;
},
getProjectById(projectId: string): ProjectRepositoryRow | null {
const db = getConnection();
const row = db.prepare(`
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
FROM projects
WHERE project_id = ?
`).get(projectId) as ProjectRepositoryRow | undefined;
return row ?? null;
},
/**
* Resolve the absolute project directory from a database project_id.
*
* This is the canonical lookup used after the projectName → projectId migration:
* API routes receive the DB-assigned `projectId` and must resolve the real folder
* path through this helper before touching the filesystem. Returns `null` when the
* project row does not exist so callers can respond with a 404.
*/
getProjectPathById(projectId: string): string | null {
const db = getConnection();
const row = db.prepare(`
SELECT project_path
FROM projects
WHERE project_id = ?
`).get(projectId) as Pick<ProjectRepositoryRow, 'project_path'> | undefined;
return row?.project_path ?? null;
},
getProjectPaths(): ProjectRepositoryRow[] {
const db = getConnection();
return db.prepare(`
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
FROM projects
WHERE isArchived = 0
`).all() as ProjectRepositoryRow[];
},
getCustomProjectName(projectPath: string): string | null {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const row = db.prepare(`
SELECT custom_project_name
FROM projects
WHERE project_path = ?
`).get(normalizedProjectPath) as Pick<ProjectRepositoryRow, 'custom_project_name'> | undefined;
return row?.custom_project_name ?? null;
},
updateCustomProjectName(projectPath: string, customProjectName: string | null): void {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
db.prepare(`
INSERT INTO projects (project_id, project_path, custom_project_name)
VALUES (?, ?, ?)
ON CONFLICT(project_path) DO UPDATE SET custom_project_name = excluded.custom_project_name
`).run(randomUUID(), normalizedProjectPath, customProjectName);
},
updateCustomProjectNameById(projectId: string, customProjectName: string | null): void {
const db = getConnection();
db.prepare(`
UPDATE projects
SET custom_project_name = ?
WHERE project_id = ?
`).run(customProjectName, projectId);
},
updateProjectIsStarred(projectPath: string, isStarred: boolean): void {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
db.prepare(`
UPDATE projects
SET isStarred = ?
WHERE project_path = ?
`).run(isStarred ? 1 : 0, normalizedProjectPath);
},
updateProjectIsStarredById(projectId: string, isStarred: boolean): void {
const db = getConnection();
db.prepare(`
UPDATE projects
SET isStarred = ?
WHERE project_id = ?
`).run(isStarred ? 1 : 0, projectId);
},
updateProjectIsArchived(projectPath: string, isArchived: boolean): void {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
db.prepare(`
UPDATE projects
SET isArchived = ?
WHERE project_path = ?
`).run(isArchived ? 1 : 0, normalizedProjectPath);
},
updateProjectIsArchivedById(projectId: string, isArchived: boolean): void {
const db = getConnection();
db.prepare(`
UPDATE projects
SET isArchived = ?
WHERE project_id = ?
`).run(isArchived ? 1 : 0, projectId);
},
deleteProjectPath(projectPath: string): void {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
db.prepare(`
DELETE FROM projects
WHERE project_path = ?
`).run(normalizedProjectPath);
},
deleteProjectById(projectId: string): void {
const db = getConnection();
db.prepare(`
DELETE FROM projects
WHERE project_id = ?
`).run(projectId);
},
};

View File

@@ -0,0 +1,80 @@
/**
* Push subscriptions repository.
*
* Persists browser push subscription endpoints and keys per user.
*/
import { getConnection } from '@/modules/database/connection.js';
type PushSubscriptionLookupRow = {
endpoint: string;
keys_p256dh: string;
keys_auth: string;
};
export const pushSubscriptionsDb = {
/** Upserts a push subscription endpoint for a user. */
createPushSubscription(
userId: number,
endpoint: string,
keysP256dh: string,
keysAuth: string
): void {
const db = getConnection();
db.prepare(
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
keys_p256dh = excluded.keys_p256dh,
keys_auth = excluded.keys_auth`
).run(userId, endpoint, keysP256dh, keysAuth);
},
/** Returns all subscriptions for a user. */
getPushSubscriptions(userId: number): PushSubscriptionLookupRow[] {
const db = getConnection();
return db
.prepare(
'SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?'
)
.all(userId) as PushSubscriptionLookupRow[];
},
/** Deletes one subscription by endpoint. */
deletePushSubscription(endpoint: string): void {
const db = getConnection();
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
},
/** Deletes all subscriptions for a user. */
deletePushSubscriptionsForUser(userId: number): void {
const db = getConnection();
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
},
// Legacy aliases used by existing services/routes
saveSubscription(
userId: number,
endpoint: string,
keysP256dh: string,
keysAuth: string
): void {
pushSubscriptionsDb.createPushSubscription(
userId,
endpoint,
keysP256dh,
keysAuth
);
},
getSubscriptions(userId: number): PushSubscriptionLookupRow[] {
return pushSubscriptionsDb.getPushSubscriptions(userId);
},
removeSubscription(endpoint: string): void {
pushSubscriptionsDb.deletePushSubscription(endpoint);
},
removeAllForUser(userId: number): void {
pushSubscriptionsDb.deletePushSubscriptionsForUser(userId);
},
};

View File

@@ -0,0 +1,42 @@
import { getConnection } from '@/modules/database/connection.js';
type ScanStateRow = {
last_scanned_at: string;
};
export const scanStateDb = {
getLastScannedAt() {
const db = getConnection();
const row = db
.prepare(`SELECT last_scanned_at FROM scan_state WHERE id = 1`)
.get() as ScanStateRow;
if (!row) {
return null; // Before any scan, the row is undefined.
}
let lastScannedDate: Date | null = null;
const lastScannedStr = row.last_scanned_at;
if (lastScannedStr) {
// SQLite CURRENT_TIMESTAMP returns UTC in "YYYY-MM-DD HH:MM:SS" format.
// Replace space with 'T' and append 'Z' to parse reliably in JS across all platforms.
lastScannedDate = new Date(lastScannedStr.replace(' ', 'T') + 'Z');
}
return lastScannedDate;
},
updateLastScannedAt(scannedAt: Date = new Date()) {
const db = getConnection();
const sqliteTimestamp = scannedAt.toISOString().slice(0, 19).replace('T', ' ');
db.prepare(`
INSERT INTO scan_state (id, last_scanned_at)
VALUES (1, ?)
ON CONFLICT (id)
DO UPDATE SET last_scanned_at = excluded.last_scanned_at
`).run(sqliteTimestamp);
}
};

View File

@@ -0,0 +1,174 @@
import { getConnection } from '@/modules/database/connection.js';
import { projectsDb } from '@/modules/database/repositories/projects.db.js';
import { normalizeProjectPath } from '@/shared/utils.js';
type SessionRow = {
session_id: string;
provider: string;
project_path: string | null;
jsonl_path: string | null;
custom_name: string | null;
created_at: string;
updated_at: string;
};
type SessionMetadataLookupRow = Pick<
SessionRow,
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'created_at' | 'updated_at'
>;
function normalizeTimestamp(value?: string): string | null {
if (!value) return null;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.toISOString();
}
function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
void provider;
return normalizeProjectPath(projectPath);
}
export const sessionsDb = {
createSession(
sessionId: string,
provider: string,
projectPath: string,
customName?: string,
createdAt?: string,
updatedAt?: string,
jsonlPath?: string | null
): string {
const db = getConnection();
const createdAtValue = normalizeTimestamp(createdAt);
const updatedAtValue = normalizeTimestamp(updatedAt);
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
// First, ensure the project path is recorded in the projects table,
// since it's a foreign key in the sessions table.
projectsDb.createProjectPath(normalizedProjectPath);
db.prepare(
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
ON CONFLICT(session_id) DO UPDATE SET
provider = excluded.provider,
updated_at = excluded.updated_at,
project_path = excluded.project_path,
jsonl_path = excluded.jsonl_path,
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
).run(
sessionId,
provider,
customName ?? null,
normalizedProjectPath,
jsonlPath ?? null,
createdAtValue,
updatedAtValue
);
return sessionId;
},
updateSessionCustomName(sessionId: string, customName: string): void {
const db = getConnection();
db.prepare(
`UPDATE sessions
SET custom_name = ?
WHERE session_id = ?`
).run(customName, sessionId);
},
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
const db = getConnection();
const row = db
.prepare(
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
FROM sessions
WHERE session_id = ?
ORDER BY updated_at DESC
LIMIT 1`
)
.get(sessionId) as SessionMetadataLookupRow | undefined;
return row ?? null;
},
getAllSessions(): SessionRow[] {
const db = getConnection();
return db
.prepare(
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
FROM sessions`
)
.all() as SessionRow[];
},
getSessionsByProjectPath(projectPath: string): SessionRow[] {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
return db
.prepare(
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
FROM sessions
WHERE project_path = ?`
)
.all(normalizedProjectPath) as SessionRow[];
},
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
return db
.prepare(
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
FROM sessions
WHERE project_path = ?
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
LIMIT ? OFFSET ?`
)
.all(normalizedProjectPath, limit, offset) as SessionRow[];
},
countSessionsByProjectPath(projectPath: string): number {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const row = db
.prepare(
`SELECT COUNT(*) AS count
FROM sessions
WHERE project_path = ?`
)
.get(normalizedProjectPath) as { count: number } | undefined;
return Number(row?.count ?? 0);
},
deleteSessionsByProjectPath(projectPath: string): void {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
db.prepare(`DELETE FROM sessions WHERE project_path = ?`).run(normalizedProjectPath);
},
getSessionName(sessionId: string, provider: string): string | null {
const db = getConnection();
const row = db
.prepare(
`SELECT custom_name
FROM sessions
WHERE session_id = ? AND provider = ?`
)
.get(sessionId, provider) as { custom_name: string | null } | undefined;
return row?.custom_name ?? null;
},
deleteSessionById(sessionId: string): boolean {
const db = getConnection();
return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0;
},
};

View File

@@ -0,0 +1,140 @@
/**
* User repository.
*
* Provides typed CRUD operations for the `users` table.
* This is a single-user system, but the schema supports multiple
* users for forward compatibility.
*/
import { getConnection } from '@/modules/database/connection.js';
type UserRow = {
id: number;
username: string;
password_hash: string;
created_at: string;
last_login: string | null;
is_active: number;
git_name: string | null;
git_email: string | null;
has_completed_onboarding: number;
};
type UserPublicRow = Pick<UserRow, 'id' | 'username' | 'created_at' | 'last_login'>;
type UserGitConfig = {
git_name: string | null;
git_email: string | null;
};
type CreateUserResult = {
id: number | bigint;
username: string;
};
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export const userDb = {
/** Returns true if at least one user exists in the database. */
hasUsers(): boolean {
const db = getConnection();
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as {
count: number;
};
return row.count > 0;
},
/** Inserts a new user and returns the created ID + username. */
createUser(username: string, passwordHash: string): CreateUserResult {
const db = getConnection();
const result = db
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
.run(username, passwordHash);
return { id: result.lastInsertRowid, username };
},
/**
* Looks up an active user by username.
* Returns the full row (including password hash) for auth verification.
*/
getUserByUsername(username: string): UserRow | undefined {
const db = getConnection();
return db
.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1')
.get(username) as UserRow | undefined;
},
/** Updates the last_login timestamp. Non-fatal — logs but does not throw. */
updateLastLogin(userId: number): void {
try {
const db = getConnection();
db.prepare(
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?'
).run(userId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('Failed to update last login', { error: message });
}
},
/** Returns public user fields by ID (no password hash). */
getUserById(userId: number): UserPublicRow | undefined {
const db = getConnection();
return db
.prepare(
'SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1'
)
.get(userId) as UserPublicRow | undefined;
},
/** Returns the first active user. Used for single-user mode lookups. */
getFirstUser(): UserPublicRow | undefined {
const db = getConnection();
return db
.prepare(
'SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1'
)
.get() as UserPublicRow | undefined;
},
/** Stores the user's preferred git name and email. */
updateGitConfig(
userId: number,
gitName: string,
gitEmail: string
): void {
const db = getConnection();
db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?').run(
gitName,
gitEmail,
userId
);
},
/** Retrieves the user's git identity (name + email). */
getGitConfig(userId: number): UserGitConfig | undefined {
const db = getConnection();
return db
.prepare('SELECT git_name, git_email FROM users WHERE id = ?')
.get(userId) as UserGitConfig | undefined;
},
/** Marks onboarding as complete for the given user. */
completeOnboarding(userId: number): void {
const db = getConnection();
db.prepare(
'UPDATE users SET has_completed_onboarding = 1 WHERE id = ?'
).run(userId);
},
/** Returns true if the user has finished the onboarding flow. */
hasCompletedOnboarding(userId: number): boolean {
const db = getConnection();
const row = db
.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?')
.get(userId) as { has_completed_onboarding: number } | undefined;
return row?.has_completed_onboarding === 1;
},
};

View File

@@ -0,0 +1,57 @@
/**
* VAPID keys repository.
*
* Stores and retrieves the Web Push VAPID key pair.
*/
import { getConnection } from '@/modules/database/connection.js';
type VapidKeyRow = {
public_key: string;
private_key: string;
};
type VapidKeyPair = {
publicKey: string;
privateKey: string;
};
export const vapidKeysDb = {
/** Returns the latest stored VAPID key pair, or null when unset. */
getVapidKeys(): VapidKeyPair | null {
const db = getConnection();
const row = db
.prepare(
'SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1'
)
.get() as Pick<VapidKeyRow, 'public_key' | 'private_key'> | undefined;
if (!row) return null;
return {
publicKey: row.public_key,
privateKey: row.private_key,
};
},
/** Persists a new VAPID key pair. */
createVapidKeys(publicKey: string, privateKey: string): void {
const db = getConnection();
db.prepare(
'INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)'
).run(publicKey, privateKey);
},
/** Replaces all existing keys with a fresh pair. */
updateVapidKeys(publicKey: string, privateKey: string): void {
const db = getConnection();
db.prepare('DELETE FROM vapid_keys').run();
vapidKeysDb.createVapidKeys(publicKey, privateKey);
},
/** Deletes all VAPID key rows. */
deleteVapidKeys(): void {
const db = getConnection();
db.prepare('DELETE FROM vapid_keys').run();
},
};

View File

@@ -0,0 +1,152 @@
const USER_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1,
git_name TEXT,
git_email TEXT,
has_completed_onboarding BOOLEAN DEFAULT 0
);
`;
export const API_KEYS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key_name TEXT NOT NULL,
api_key TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
export const USER_CREDENTIALS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS user_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_name TEXT NOT NULL,
credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc.
credential_value TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
export const USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
export const VAPID_KEYS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`;
export const PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
export const PROJECTS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY NOT NULL,
project_path TEXT NOT NULL UNIQUE,
custom_project_name TEXT DEFAULT NULL,
isStarred BOOLEAN DEFAULT 0,
isArchived BOOLEAN DEFAULT 0
);
`;
export const SESSIONS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT,
project_path TEXT,
jsonl_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (session_id),
FOREIGN KEY (project_path) REFERENCES projects(project_path)
ON DELETE SET NULL
ON UPDATE CASCADE
);
`;
export const LAST_SCANNED_AT_SQL = `
CREATE TABLE IF NOT EXISTS scan_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_scanned_at TIMESTAMP NULL
);
`;
export const APP_CONFIG_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`;
export const INIT_SCHEMA_SQL = `
-- Initialize authentication database
PRAGMA foreign_keys = ON;
${USER_TABLE_SCHEMA_SQL}
-- Indexes for performance for user lookups
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
${API_KEYS_TABLE_SCHEMA_SQL}
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
${USER_CREDENTIALS_TABLE_SCHEMA_SQL}
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
${USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL}
CREATE INDEX IF NOT EXISTS idx_user_notification_preferences_user_id ON user_notification_preferences(user_id);
${VAPID_KEYS_TABLE_SCHEMA_SQL}
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
${PROJECTS_TABLE_SCHEMA_SQL}
-- NOTE: These indexes are created in migrations after legacy table-shape repairs.
-- Creating them here can fail on upgraded installs where projects lacks those columns.
${SESSIONS_TABLE_SCHEMA_SQL}
CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id);
-- NOTE: This index is created in migrations after sessions is rebuilt to include project_path.
-- Creating it here can fail on upgraded installs where the legacy sessions table has no project_path.
${LAST_SCANNED_AT_SQL}
${APP_CONFIG_TABLE_SCHEMA_SQL}
`;

View File

@@ -0,0 +1,6 @@
export {
generateDisplayName,
getProjectsWithSessions,
} from './services/projects-with-sessions-fetch.service.js';
export { updateProjectDisplayName } from './services/project-management.service.js';
export { deleteOrArchiveProject, deleteSessionJsonlFilesForProjectPath } from './services/project-delete.service.js';

View File

@@ -0,0 +1,247 @@
import express from 'express';
import { createProject, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js';
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js';
import { AppError, asyncHandler } from '@/shared/utils.js';
import { getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
import { deleteOrArchiveProject } from '@/modules/projects/services/project-delete.service.js';
import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
const router = express.Router();
type AuthenticatedUser = {
id?: number | string;
};
function readQueryStringValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0];
}
return '';
}
function readOptionalNumericQueryValue(value: unknown): number | null {
const rawValue = readQueryStringValue(value).trim();
if (!rawValue) {
return null;
}
const parsedValue = Number.parseInt(rawValue, 10);
return Number.isNaN(parsedValue) ? null : parsedValue;
}
function parseNonNegativeIntQuery(value: unknown, name: string, fallback: number): number {
const rawValue = readQueryStringValue(value).trim();
if (!rawValue) {
return fallback;
}
const parsedValue = Number.parseInt(rawValue, 10);
if (Number.isNaN(parsedValue) || parsedValue < 0) {
throw new AppError(`${name} must be a non-negative integer`, {
code: 'INVALID_QUERY_PARAMETER',
statusCode: 400,
});
}
return parsedValue;
}
function resolveRouteErrorMessage(error: unknown): string {
if (error instanceof AppError) {
return error.message;
}
if (error instanceof Error && error.message) {
return error.message;
}
return 'Failed to clone repository';
}
router.get(
'/',
asyncHandler(async (_req, res) => {
const projects = await getProjectsWithSessions();
res.json(projects);
}),
);
router.get(
'/:projectId/sessions',
asyncHandler(async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
const limit = parseNonNegativeIntQuery(req.query.limit, 'limit', 20);
const offset = parseNonNegativeIntQuery(req.query.offset, 'offset', 0);
const sessionsPage = await getProjectSessionsPage(projectId, { limit, offset });
res.json(sessionsPage);
}),
);
router.post(
'/create-project',
asyncHandler(async (req, res) => {
const requestBody = req.body as Record<string, unknown>;
const projectPath = typeof requestBody.path === 'string' ? requestBody.path : '';
const customName = typeof requestBody.customName === 'string' ? requestBody.customName : null;
if (requestBody.workspaceType !== undefined) {
throw new AppError('workspaceType is no longer supported. Use the single create-project flow.', {
code: 'LEGACY_WORKSPACE_TYPE_UNSUPPORTED',
statusCode: 400,
});
}
if (requestBody.githubUrl || requestBody.githubTokenId || requestBody.newGithubToken) {
throw new AppError('Repository cloning is not supported on create-project', {
code: 'CLONE_NOT_SUPPORTED_ON_CREATE_PROJECT',
statusCode: 400,
details: 'Use /api/projects/clone-progress for cloning workflows',
});
}
const projectCreationResult = await createProject({
projectPath,
customName,
});
res.json({
success: true,
project: projectCreationResult.project,
message:
projectCreationResult.outcome === 'reactivated_archived'
? 'Archived project path reused successfully'
: 'Project created successfully',
});
}),
);
/**
* One-time (or idempotent) migration: apply legacy `localStorage` starred projectIds to the DB, then clear client storage.
*/
router.post(
'/migrate-legacy-stars',
asyncHandler(async (req, res) => {
const projectIds = Array.isArray((req.body as { projectIds?: unknown })?.projectIds)
? ((req.body as { projectIds: unknown[] }).projectIds as unknown[]).map((x) => String(x))
: [];
const { updated } = applyLegacyStarredProjectIds(projectIds);
res.json({ success: true, updated });
}),
);
router.get('/clone-progress', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendEvent = (type: string, data: Record<string, unknown>) => {
if (res.writableEnded) {
return;
}
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
};
let cloneOperation: Awaited<ReturnType<typeof startCloneProject>> | null = null;
const closeListener = () => {
cloneOperation?.cancel();
};
req.on('close', closeListener);
try {
const queryParams = req.query as Record<string, unknown>;
const workspacePath = readQueryStringValue(queryParams.path);
const githubUrl = readQueryStringValue(queryParams.githubUrl);
const githubTokenId = readOptionalNumericQueryValue(queryParams.githubTokenId);
const newGithubToken = readQueryStringValue(queryParams.newGithubToken) || null;
const authenticatedUser = (req as typeof req & { user?: AuthenticatedUser }).user;
const userId = authenticatedUser?.id;
if (userId === undefined || userId === null) {
throw new AppError('Authenticated user is required', {
code: 'AUTHENTICATION_REQUIRED',
statusCode: 401,
});
}
cloneOperation = await startCloneProject(
{
workspacePath,
githubUrl,
githubTokenId,
newGithubToken,
userId,
},
{
onProgress: (message) => {
sendEvent('progress', { message });
},
onComplete: ({ project, message }) => {
sendEvent('complete', { project, message });
},
},
);
await cloneOperation.waitForCompletion;
} catch (error) {
sendEvent('error', { message: resolveRouteErrorMessage(error) });
} finally {
req.off('close', closeListener);
if (!res.writableEnded) {
res.end();
}
}
});
router.get(
'/:projectId/taskmaster',
asyncHandler(async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
const taskMasterDetails = await getProjectTaskMaster(projectId);
res.json(taskMasterDetails);
}),
);
router.put('/:projectId/rename', (req, res) => {
try {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
const { displayName } = req.body as { displayName?: unknown };
updateProjectDisplayName(projectId, displayName);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to rename project' });
}
});
router.post(
'/:projectId/toggle-star',
asyncHandler(async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
const { isStarred } = toggleProjectStar(projectId);
res.json({ success: true, isStarred });
}),
);
/**
* - `force` not set / false: archive project in DB only (`isArchived` = 1; hidden from active list).
* - `force=true`: remove DB row, delete session rows for that path, remove all `*.jsonl` under the Claude project dir.
*/
router.delete(
'/:projectId',
asyncHandler(async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
const force = req.query.force === 'true';
await deleteOrArchiveProject(projectId, force);
res.json({ success: true });
}),
);
export default router;

View File

@@ -0,0 +1,321 @@
import { spawn } from 'node:child_process';
import { access, mkdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { githubTokensDb } from '@/modules/database/index.js';
import { createProject } from '@/modules/projects/services/project-management.service.js';
import type { WorkspacePathValidationResult } from '@/shared/types.js';
import { AppError, validateWorkspacePath } from '@/shared/utils.js';
type CloneProjectInput = {
workspacePath: string;
githubUrl: string;
githubTokenId?: number | null;
newGithubToken?: string | null;
userId: number | string;
};
type CloneCompletePayload = {
project: Record<string, unknown>;
message: string;
};
type CloneProjectEventHandlers = {
onProgress: (message: string) => void;
onComplete: (payload: CloneCompletePayload) => void;
};
type GitCloneProcess = {
stdout: NodeJS.ReadableStream | null;
stderr: NodeJS.ReadableStream | null;
on(event: 'close', listener: (code: number | null) => void): void;
on(event: 'error', listener: (error: NodeJS.ErrnoException) => void): void;
kill(): void;
};
type CloneProjectDependencies = {
validatePath: (requestedPath: string) => Promise<WorkspacePathValidationResult>;
ensureDirectory: (directoryPath: string) => Promise<void>;
pathExists: (targetPath: string) => Promise<boolean>;
removePath: (targetPath: string) => Promise<void>;
getGithubTokenById: (
tokenId: number,
userId: number,
) => Promise<{ github_token: string } | null>;
spawnGitClone: (cloneUrl: string, clonePath: string) => GitCloneProcess;
registerProject: (projectPath: string, customName: string) => Promise<{ project: Record<string, unknown> }>;
logError: (message: string, error: unknown) => void;
};
export type CloneProjectOperation = {
waitForCompletion: Promise<void>;
cancel: () => void;
};
async function defaultPathExists(targetPath: string): Promise<boolean> {
try {
await access(targetPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
}
function sanitizeGitError(message: string, token: string | null): string {
if (!message || !token) {
return message;
}
const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return message.replace(new RegExp(escapedToken, 'g'), '***');
}
function resolveCloneFailureMessage(lastError: string, sanitizedError: string): string {
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
return 'Authentication failed. Please check your credentials.';
}
if (lastError.includes('Repository not found')) {
return 'Repository not found. Please check the URL and ensure you have access.';
}
if (lastError.includes('already exists')) {
return 'Directory already exists';
}
if (sanitizedError) {
return sanitizedError;
}
return 'Git clone failed';
}
function resolveErrorMessage(error: unknown): string {
if (error instanceof AppError) {
return error.message;
}
if (error instanceof Error && error.message) {
return error.message;
}
return 'Unexpected error';
}
const defaultDependencies: CloneProjectDependencies = {
validatePath: validateWorkspacePath,
ensureDirectory: async (directoryPath: string): Promise<void> => {
await mkdir(directoryPath, { recursive: true });
},
pathExists: defaultPathExists,
removePath: async (targetPath: string): Promise<void> => {
await rm(targetPath, { recursive: true, force: true });
},
getGithubTokenById: async (
tokenId: number,
userId: number,
): Promise<{ github_token: string } | null> => {
const tokenRow = githubTokensDb.getGithubTokenById(userId, tokenId) as
| { github_token: string }
| null;
return tokenRow;
},
spawnGitClone: (cloneUrl: string, clonePath: string): GitCloneProcess =>
spawn('git', ['clone', '--progress', '--', cloneUrl, clonePath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0',
},
}) as unknown as GitCloneProcess,
registerProject: async (
projectPath: string,
customName: string,
): Promise<{ project: Record<string, unknown> }> =>
createProject({
projectPath,
customName,
}) as Promise<{ project: Record<string, unknown> }>,
logError: (message: string, error: unknown): void => {
console.error(message, error);
},
};
export async function startCloneProject(
input: CloneProjectInput,
handlers: CloneProjectEventHandlers,
dependencies: CloneProjectDependencies = defaultDependencies,
): Promise<CloneProjectOperation> {
const normalizedWorkspacePath = input.workspacePath.trim();
const normalizedGithubUrl = input.githubUrl.trim();
if (!normalizedWorkspacePath) {
throw new AppError('workspacePath and githubUrl are required', {
code: 'WORKSPACE_PATH_REQUIRED',
statusCode: 400,
});
}
if (!normalizedGithubUrl) {
throw new AppError('workspacePath and githubUrl are required', {
code: 'GITHUB_URL_REQUIRED',
statusCode: 400,
});
}
if (normalizedGithubUrl.startsWith('-')) {
throw new AppError('Invalid githubUrl', {
code: 'INVALID_GITHUB_URL',
statusCode: 400,
});
}
const pathValidation = await dependencies.validatePath(normalizedWorkspacePath);
if (!pathValidation.valid || !pathValidation.resolvedPath) {
throw new AppError(pathValidation.error || 'Invalid workspace path', {
code: 'INVALID_PROJECT_PATH',
statusCode: 400,
});
}
const absolutePath = pathValidation.resolvedPath;
await dependencies.ensureDirectory(absolutePath);
let githubToken: string | null = null;
if (typeof input.githubTokenId === 'number') {
const numericUserId =
typeof input.userId === 'number' ? input.userId : Number.parseInt(String(input.userId), 10);
if (Number.isNaN(numericUserId)) {
throw new AppError('Authenticated user is required', {
code: 'AUTHENTICATION_REQUIRED',
statusCode: 401,
});
}
const token = await dependencies.getGithubTokenById(input.githubTokenId, numericUserId);
if (!token) {
throw new AppError('GitHub token not found', {
code: 'GITHUB_TOKEN_NOT_FOUND',
statusCode: 404,
});
}
githubToken = token.github_token;
} else if (input.newGithubToken && input.newGithubToken.trim().length > 0) {
githubToken = input.newGithubToken.trim();
}
const sanitizedGithubUrl = normalizedGithubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
const repoName = sanitizedGithubUrl.split('/').pop() || 'repository';
const clonePath = path.join(absolutePath, repoName);
if (await dependencies.pathExists(clonePath)) {
throw new AppError(
`Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.`,
{
code: 'CLONE_TARGET_ALREADY_EXISTS',
statusCode: 409,
},
);
}
let cloneUrl = normalizedGithubUrl;
if (githubToken) {
try {
const url = new URL(normalizedGithubUrl);
url.username = githubToken;
url.password = '';
cloneUrl = url.toString();
} catch {
// SSH URLs cannot be represented by URL constructor and are used as-is.
}
}
handlers.onProgress(`Cloning into '${repoName}'...`);
const gitProcess = dependencies.spawnGitClone(cloneUrl, clonePath);
let lastError = '';
gitProcess.stdout?.on('data', (data: Buffer | string) => {
const message = data.toString().trim();
if (message) {
handlers.onProgress(message);
}
});
gitProcess.stderr?.on('data', (data: Buffer | string) => {
const message = data.toString().trim();
lastError = message;
if (message) {
handlers.onProgress(message);
}
});
const waitForCompletion = new Promise<void>((resolve, reject) => {
gitProcess.on('close', async (code) => {
if (code === 0) {
try {
const createdProject = await dependencies.registerProject(clonePath, repoName);
handlers.onComplete({
project: createdProject.project,
message: 'Repository cloned successfully',
});
resolve();
} catch (error) {
reject(
new AppError(`Clone succeeded but failed to add project: ${resolveErrorMessage(error)}`, {
code: 'CLONE_PROJECT_REGISTRATION_FAILED',
statusCode: 500,
}),
);
}
return;
}
const sanitizedError = sanitizeGitError(lastError, githubToken);
const errorMessage = resolveCloneFailureMessage(lastError, sanitizedError);
try {
await dependencies.removePath(clonePath);
} catch (cleanupError) {
dependencies.logError('Failed to clean up after clone failure:', cleanupError);
}
reject(
new AppError(errorMessage, {
code: 'GIT_CLONE_FAILED',
statusCode: 500,
}),
);
});
gitProcess.on('error', (error) => {
if (error.code === 'ENOENT') {
reject(
new AppError('Git is not installed or not in PATH', {
code: 'GIT_NOT_FOUND',
statusCode: 500,
}),
);
return;
}
reject(
new AppError(error.message, {
code: 'GIT_EXECUTION_FAILED',
statusCode: 500,
}),
);
});
});
return {
waitForCompletion,
cancel: () => {
gitProcess.kill();
},
};
}

View File

@@ -0,0 +1,75 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { AppError } from '@/shared/utils.js';
function uniqueJsonlPathsFromSessions(
sessions: Array<{ jsonl_path: string | null }>,
): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const row of sessions) {
const raw = row.jsonl_path?.trim();
if (!raw) {
continue;
}
const absolute = path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(raw);
if (seen.has(absolute)) {
continue;
}
seen.add(absolute);
result.push(absolute);
}
return result;
}
async function unlinkJsonlIfExists(filePath: string): Promise<void> {
try {
await fs.unlink(filePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return;
}
console.warn(`[project-delete] Failed to remove ${filePath}:`, (error as Error).message);
}
}
/**
* Loads all session rows for the project path and removes each distinct `jsonl_path` file on disk.
*/
export async function deleteSessionJsonlFilesForProjectPath(projectPath: string): Promise<void> {
const sessions = sessionsDb.getSessionsByProjectPath(projectPath);
const paths = uniqueJsonlPathsFromSessions(sessions);
for (const filePath of paths) {
await unlinkJsonlIfExists(filePath);
}
}
/**
* - **Soft delete** (`force` false): set `isArchived` on the `projects` row (hide from the active list; DB only).
* - **Force** (`force` true): for each session row for that `project_path`, delete the file at `jsonl_path`
* (when set), then remove session rows and the `projects` row.
*/
export async function deleteOrArchiveProject(projectId: string, force: boolean): Promise<void> {
const row = projectsDb.getProjectById(projectId);
if (!row) {
throw new AppError(`Unknown projectId: ${projectId}`, {
code: 'PROJECT_NOT_FOUND',
statusCode: 404,
});
}
if (!force) {
projectsDb.updateProjectIsArchivedById(projectId, true);
return;
}
await deleteSessionJsonlFilesForProjectPath(row.project_path);
sessionsDb.deleteSessionsByProjectPath(row.project_path);
projectsDb.deleteProjectById(projectId);
}

View File

@@ -0,0 +1,150 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { projectsDb } from '@/modules/database/index.js';
import type {
CreateProjectPathResult,
ProjectRepositoryRow,
WorkspacePathValidationResult,
} from '@/shared/types.js';
import { AppError, normalizeProjectPath, validateWorkspacePath } from '@/shared/utils.js';
type CreateProjectInput = {
projectPath: string;
customName?: string | null;
};
type CreateProjectDependencies = {
validatePath: (projectPath: string) => Promise<WorkspacePathValidationResult>;
ensureWorkspaceDirectory: (projectPath: string) => Promise<void>;
persistProjectPath: (projectPath: string, customName: string | null) => CreateProjectPathResult;
getProjectByPath: (projectPath: string) => ProjectRepositoryRow | null;
};
type ProjectApiView = {
projectId: string;
path: string;
fullPath: string;
displayName: string;
customName: string | null;
isArchived: boolean;
isStarred: boolean;
sessions: [];
cursorSessions: [];
codexSessions: [];
geminiSessions: [];
sessionMeta: {
hasMore: false;
total: 0;
};
};
type CreateProjectServiceResult = {
outcome: 'created' | 'reactivated_archived';
project: ProjectApiView;
};
const defaultDependencies: CreateProjectDependencies = {
validatePath: validateWorkspacePath,
ensureWorkspaceDirectory: async (projectPath: string): Promise<void> => {
await fs.mkdir(projectPath, { recursive: true });
const directoryStats = await fs.stat(projectPath);
if (!directoryStats.isDirectory()) {
throw new AppError('Path exists but is not a directory', {
code: 'PROJECT_PATH_NOT_DIRECTORY',
statusCode: 400,
});
}
},
persistProjectPath: (projectPath: string, customName: string | null): CreateProjectPathResult =>
projectsDb.createProjectPath(projectPath, customName),
getProjectByPath: (projectPath: string): ProjectRepositoryRow | null =>
projectsDb.getProjectPath(projectPath),
};
function resolveDisplayName(customName: string | null | undefined, projectPath: string): string {
const trimmedCustomName = typeof customName === 'string' ? customName.trim() : '';
if (trimmedCustomName.length > 0) {
return trimmedCustomName;
}
return path.basename(projectPath) || projectPath;
}
function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiView {
return {
projectId: projectRow.project_id,
path: projectRow.project_path,
fullPath: projectRow.project_path,
displayName: resolveDisplayName(projectRow.custom_project_name, projectRow.project_path),
customName: projectRow.custom_project_name,
isArchived: Boolean(projectRow.isArchived),
isStarred: Boolean(projectRow.isStarred),
sessions: [],
cursorSessions: [],
codexSessions: [],
geminiSessions: [],
sessionMeta: {
hasMore: false,
total: 0,
},
};
}
export async function createProject(
input: CreateProjectInput,
dependencies: CreateProjectDependencies = defaultDependencies,
): Promise<CreateProjectServiceResult> {
const normalizedPath = normalizeProjectPath(input.projectPath || '');
if (!normalizedPath) {
throw new AppError('path is required', {
code: 'PROJECT_PATH_REQUIRED',
statusCode: 400,
});
}
const pathValidation = await dependencies.validatePath(normalizedPath);
if (!pathValidation.valid || !pathValidation.resolvedPath) {
throw new AppError('Invalid project path', {
code: 'INVALID_PROJECT_PATH',
statusCode: 400,
details: pathValidation.error ?? 'Path validation failed',
});
}
const resolvedProjectPath = normalizeProjectPath(pathValidation.resolvedPath);
await dependencies.ensureWorkspaceDirectory(resolvedProjectPath);
const normalizedCustomName = resolveDisplayName(input.customName ?? null, resolvedProjectPath);
const persistedProject = dependencies.persistProjectPath(resolvedProjectPath, normalizedCustomName);
if (persistedProject.outcome === 'active_conflict') {
throw new AppError('Project path already exists and is active', {
code: 'PROJECT_ALREADY_EXISTS',
statusCode: 409,
details: `Project path already exists: ${resolvedProjectPath}`,
});
}
const projectRow = persistedProject.project ?? dependencies.getProjectByPath(resolvedProjectPath);
if (!projectRow) {
throw new AppError('Failed to resolve project after creation', {
code: 'PROJECT_CREATE_FAILED',
statusCode: 500,
});
}
// Archived rows intentionally remain archived when reused, as requested.
return {
outcome: persistedProject.outcome,
project: mapProjectRowToApiView(projectRow),
};
}
/**
* Sets `projects.custom_project_name` for the given `projectId` (or clears it when empty).
*/
export function updateProjectDisplayName(projectId: string, newDisplayName: unknown): void {
const trimmed = typeof newDisplayName === 'string' ? newDisplayName.trim() : '';
projectsDb.updateCustomProjectNameById(projectId, trimmed.length > 0 ? trimmed : null);
}

View File

@@ -0,0 +1,78 @@
import { projectsDb } from '@/modules/database/index.js';
import { AppError } from '@/shared/utils.js';
type ToggleProjectStarResult = {
isStarred: boolean;
};
type ApplyLegacyStarredProjectIdsResult = {
updated: number;
};
function normalizeProjectId(projectId: string): string {
return projectId.trim();
}
function uniqueProjectIds(projectIds: string[]): string[] {
const uniqueIds = new Set<string>();
for (const projectId of projectIds) {
const normalizedProjectId = normalizeProjectId(projectId);
if (!normalizedProjectId) {
continue;
}
uniqueIds.add(normalizedProjectId);
}
return [...uniqueIds];
}
/**
* Applies legacy `localStorage` stars keyed by DB `projectId` onto `projects.isStarred`.
*
* The operation is idempotent: already-starred projects are ignored, unknown ids are skipped.
*/
export function applyLegacyStarredProjectIds(projectIds: string[]): ApplyLegacyStarredProjectIdsResult {
const normalizedProjectIds = uniqueProjectIds(projectIds);
let updated = 0;
for (const projectId of normalizedProjectIds) {
const project = projectsDb.getProjectById(projectId);
if (!project) {
continue;
}
if (Boolean(project.isStarred)) {
continue;
}
projectsDb.updateProjectIsStarredById(projectId, true);
updated += 1;
}
return { updated };
}
/**
* Flips `projects.isStarred` for one project and returns the new state.
*/
export function toggleProjectStar(projectId: string): ToggleProjectStarResult {
const normalizedProjectId = normalizeProjectId(projectId);
if (!normalizedProjectId) {
throw new AppError('projectId is required', {
code: 'PROJECT_ID_REQUIRED',
statusCode: 400,
});
}
const project = projectsDb.getProjectById(normalizedProjectId);
if (!project) {
throw new AppError('Project not found', {
code: 'PROJECT_NOT_FOUND',
statusCode: 404,
});
}
const nextStarredState = !Boolean(project.isStarred);
projectsDb.updateProjectIsStarredById(normalizedProjectId, nextStarredState);
return { isStarred: nextStarredState };
}

View File

@@ -0,0 +1,248 @@
import { access, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { projectsDb } from '@/modules/database/index.js';
import { AppError } from '@/shared/utils.js';
type TaskMasterTask = {
status?: string;
subtasks?: Array<{
status?: string;
}>;
};
type TaskMasterMetadata =
| {
taskCount: number;
subtaskCount: number;
completed: number;
pending: number;
inProgress: number;
review: number;
completionPercentage: number;
lastModified: string;
}
| {
error: string;
}
| null;
type TaskMasterDetectionResult = {
hasTaskmaster: boolean;
hasEssentialFiles?: boolean;
files?: Record<string, boolean>;
metadata?: TaskMasterMetadata;
path?: string;
reason?: string;
};
type NormalizedTaskMasterInfo = {
hasTaskmaster: boolean;
hasEssentialFiles: boolean;
metadata: TaskMasterMetadata;
status: 'configured' | 'not-configured';
};
type GetProjectTaskMasterByIdResult = {
projectId: string;
projectPath: string;
taskmaster: NormalizedTaskMasterInfo;
};
type GetProjectTaskMasterDependencies = {
resolveProjectPathById: (projectId: string) => string | null;
detectTaskMasterFolder: (projectPath: string) => Promise<TaskMasterDetectionResult>;
};
type GetProjectTaskMasterResolver = (projectId: string) => Promise<GetProjectTaskMasterByIdResult | null>;
function extractTasksFromJson(tasksData: unknown): TaskMasterTask[] {
if (!tasksData || typeof tasksData !== 'object') {
return [];
}
const legacyTasks = (tasksData as { tasks?: unknown }).tasks;
if (Array.isArray(legacyTasks)) {
return legacyTasks as TaskMasterTask[];
}
const taggedTaskCollections: TaskMasterTask[] = [];
for (const tagValue of Object.values(tasksData)) {
if (!tagValue || typeof tagValue !== 'object') {
continue;
}
const tagTasks = (tagValue as { tasks?: unknown }).tasks;
if (Array.isArray(tagTasks)) {
taggedTaskCollections.push(...(tagTasks as TaskMasterTask[]));
}
}
return taggedTaskCollections;
}
async function detectTaskMasterFolder(projectPath: string): Promise<TaskMasterDetectionResult> {
try {
const taskMasterPath = path.join(projectPath, '.taskmaster');
try {
const taskMasterStats = await stat(taskMasterPath);
if (!taskMasterStats.isDirectory()) {
return {
hasTaskmaster: false,
reason: '.taskmaster exists but is not a directory',
};
}
} catch (error) {
const fileError = error as NodeJS.ErrnoException;
if (fileError.code === 'ENOENT') {
return {
hasTaskmaster: false,
reason: '.taskmaster directory not found',
};
}
throw fileError;
}
const keyFiles = ['tasks/tasks.json', 'config.json'];
const fileStatus: Record<string, boolean> = {};
let hasEssentialFiles = true;
for (const fileName of keyFiles) {
const absoluteFilePath = path.join(taskMasterPath, fileName);
try {
await access(absoluteFilePath);
fileStatus[fileName] = true;
} catch {
fileStatus[fileName] = false;
if (fileName === 'tasks/tasks.json') {
hasEssentialFiles = false;
}
}
}
let taskMetadata: TaskMasterMetadata = null;
if (fileStatus['tasks/tasks.json']) {
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
try {
const tasksContent = await readFile(tasksPath, 'utf8');
const parsedTasksJson = JSON.parse(tasksContent) as unknown;
const tasks = extractTasksFromJson(parsedTasksJson);
const stats = tasks.reduce(
(accumulator, currentTask) => {
accumulator.total += 1;
const normalizedTaskStatus = currentTask.status || 'pending';
accumulator.byStatus[normalizedTaskStatus] = (accumulator.byStatus[normalizedTaskStatus] || 0) + 1;
if (Array.isArray(currentTask.subtasks)) {
for (const subtask of currentTask.subtasks) {
accumulator.subtotalTasks += 1;
const normalizedSubtaskStatus = subtask.status || 'pending';
accumulator.subtaskByStatus[normalizedSubtaskStatus] =
(accumulator.subtaskByStatus[normalizedSubtaskStatus] || 0) + 1;
}
}
return accumulator;
},
{
total: 0,
subtotalTasks: 0,
byStatus: {} as Record<string, number>,
subtaskByStatus: {} as Record<string, number>,
},
);
const tasksStat = await stat(tasksPath);
taskMetadata = {
taskCount: stats.total,
subtaskCount: stats.subtotalTasks,
completed: stats.byStatus.done || 0,
pending: stats.byStatus.pending || 0,
inProgress: stats.byStatus['in-progress'] || 0,
review: stats.byStatus.review || 0,
completionPercentage: stats.total > 0 ? Math.round(((stats.byStatus.done || 0) / stats.total) * 100) : 0,
lastModified: tasksStat.mtime.toISOString(),
};
} catch (parseError) {
console.warn('Failed to parse tasks.json:', (parseError as Error).message);
taskMetadata = {
error: 'Failed to parse tasks.json',
};
}
}
return {
hasTaskmaster: true,
hasEssentialFiles,
files: fileStatus,
metadata: taskMetadata,
path: taskMasterPath,
};
} catch (error) {
console.error('Error detecting TaskMaster folder:', error);
return {
hasTaskmaster: false,
reason: `Error checking directory: ${(error as Error).message}`,
};
}
}
function normalizeTaskMasterInfo(taskMasterResult: TaskMasterDetectionResult | null = null): NormalizedTaskMasterInfo {
const hasTaskmaster = Boolean(taskMasterResult?.hasTaskmaster);
const hasEssentialFiles = Boolean(taskMasterResult?.hasEssentialFiles);
return {
hasTaskmaster,
hasEssentialFiles,
metadata: taskMasterResult?.metadata ?? null,
status: hasTaskmaster && hasEssentialFiles ? 'configured' : 'not-configured',
};
}
const defaultDependencies: GetProjectTaskMasterDependencies = {
resolveProjectPathById: (projectId: string): string | null => projectsDb.getProjectPathById(projectId),
detectTaskMasterFolder,
};
export async function getProjectTaskMasterById(
projectId: string,
dependencies: GetProjectTaskMasterDependencies = defaultDependencies,
): Promise<GetProjectTaskMasterByIdResult | null> {
const projectPath = dependencies.resolveProjectPathById(projectId);
if (!projectPath) {
return null;
}
const taskMasterResult = await dependencies.detectTaskMasterFolder(projectPath);
return {
projectId,
projectPath,
taskmaster: normalizeTaskMasterInfo(taskMasterResult),
};
}
export async function getProjectTaskMaster(
projectId: string,
resolveById: GetProjectTaskMasterResolver = getProjectTaskMasterById,
): Promise<GetProjectTaskMasterByIdResult> {
const normalizedProjectId = projectId.trim();
if (!normalizedProjectId) {
throw new AppError('projectId is required', {
code: 'PROJECT_ID_REQUIRED',
statusCode: 400,
});
}
const taskMasterDetails = await resolveById(normalizedProjectId);
if (!taskMasterDetails) {
throw new AppError('Project not found', {
code: 'PROJECT_NOT_FOUND',
statusCode: 404,
});
}
return taskMasterDetails;
}

View File

@@ -0,0 +1,285 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { sessionSynchronizerService } from '@/modules/providers/index.js';
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
import type { RealtimeClientConnection } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
type SessionSummary = {
id: string;
summary: string;
messageCount: number;
lastActivity: string;
};
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>;
type SessionRepositoryRow = {
provider: string;
session_id: string;
custom_name?: string | null;
updated_at?: string | null;
created_at?: string | null;
};
export type ProjectListItem = {
projectId: string;
path: string;
displayName: string;
fullPath: string;
isStarred: boolean;
sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
sessionMeta: {
hasMore: boolean;
total: number;
};
};
type ProgressUpdate = {
phase: 'loading' | 'complete';
current: number;
total: number;
currentProject?: string;
};
type GetProjectsWithSessionsOptions = {
skipSynchronization?: boolean;
sessionsLimit?: number;
sessionsOffset?: number;
};
type SessionPaginationOptions = {
limit?: number;
offset?: number;
};
type ProjectSessionsPageResult = {
sessionsByProvider: SessionsByProvider;
total: number;
hasMore: boolean;
};
export type ProjectSessionsPageApiView = {
projectId: string;
sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
sessionMeta: {
hasMore: boolean;
total: number;
};
};
const DEFAULT_PROJECT_SESSIONS_PAGE_SIZE = 20;
const MAX_PROJECT_SESSIONS_PAGE_SIZE = 200;
/**
* Generate better display name from path.
*/
export async function generateDisplayName(projectName: string, actualProjectDir: string | null = null): Promise<string> {
// Use actual project directory if provided, otherwise decode from project name.
const projectPath = actualProjectDir || projectName.replace(/-/g, '/');
// Try to read package.json from the project path.
try {
const packageJsonPath = path.join(projectPath, 'package.json');
const packageData = await fs.readFile(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageData) as { name?: string };
// Return the name from package.json if it exists.
if (packageJson.name) {
return packageJson.name;
}
} catch {
// Fall back to path-based naming if package.json doesn't exist or can't be read.
}
// If it starts with /, it's an absolute path.
if (projectPath.startsWith('/')) {
const parts = projectPath.split('/').filter(Boolean);
// Return only the last folder name.
return parts[parts.length - 1] || projectPath;
}
return projectPath;
}
function normalizeSessionPagination(options: SessionPaginationOptions = {}): { limit: number; offset: number } {
const rawLimit = Number.isFinite(options.limit) ? Math.floor(Number(options.limit)) : DEFAULT_PROJECT_SESSIONS_PAGE_SIZE;
const rawOffset = Number.isFinite(options.offset) ? Math.floor(Number(options.offset)) : 0;
return {
limit: Math.min(Math.max(1, rawLimit), MAX_PROJECT_SESSIONS_PAGE_SIZE),
offset: Math.max(0, rawOffset),
};
}
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
return {
id: row.session_id,
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
};
}
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
const byProvider: SessionsByProvider = {
claude: [],
cursor: [],
codex: [],
gemini: [],
};
for (const row of rows) {
const provider = row.provider as keyof SessionsByProvider;
const bucket = byProvider[provider];
if (!bucket) {
continue;
}
bucket.push(mapSessionRowToSummary(row));
}
return byProvider;
}
/**
* Reads one paginated project session slice from the DB and groups rows by provider.
*/
function readProjectSessionsPageByPath(
projectPath: string,
options: SessionPaginationOptions = {},
): ProjectSessionsPageResult {
const pagination = normalizeSessionPagination(options);
const rows = sessionsDb.getSessionsByProjectPathPage(
projectPath,
pagination.limit,
pagination.offset,
) as SessionRepositoryRow[];
const total = sessionsDb.countSessionsByProjectPath(projectPath);
return {
sessionsByProvider: bucketSessionRowsByProvider(rows),
total,
hasMore: pagination.offset + rows.length < total,
};
}
// Broadcast progress to all connected WebSocket clients
function broadcastProgress(progress: ProgressUpdate) {
const message = JSON.stringify({
type: 'loading_progress',
...progress,
});
connectedClients.forEach((client: RealtimeClientConnection) => {
if (client.readyState === WS_OPEN_STATE) {
client.send(message);
}
});
}
/**
* Reads all projects from DB and returns provider-bucketed session summaries.
*/
export async function getProjectsWithSessions(
options: GetProjectsWithSessionsOptions = {}
): Promise<ProjectListItem[]> {
if (!options.skipSynchronization) {
await sessionSynchronizerService.synchronizeSessions();
}
const projectRows = projectsDb.getProjectPaths() as Array<{
project_id: string;
project_path: string;
custom_project_name?: string | null;
isStarred?: number;
}>;
const totalProjects = projectRows.length;
const projects: ProjectListItem[] = [];
let processedProjects = 0;
for (const row of projectRows) {
processedProjects += 1;
const projectId = row.project_id;
const projectPath = row.project_path;
broadcastProgress({
phase: 'loading',
current: processedProjects,
total: totalProjects,
currentProject: projectPath,
});
const displayName =
row.custom_project_name && row.custom_project_name.trim().length > 0
? row.custom_project_name
: await generateDisplayName(path.basename(projectPath) || projectPath, projectPath);
const sessionsPage = readProjectSessionsPageByPath(projectPath, {
limit: options.sessionsLimit,
offset: options.sessionsOffset,
});
projects.push({
projectId,
path: projectPath,
displayName,
fullPath: projectPath,
isStarred: Boolean(row.isStarred),
sessions: sessionsPage.sessionsByProvider.claude,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
sessionMeta: {
hasMore: sessionsPage.hasMore,
total: sessionsPage.total,
},
});
}
broadcastProgress({
phase: 'complete',
current: totalProjects,
total: totalProjects,
});
return projects;
}
/**
* Loads one paginated session slice for a specific project id.
*/
export async function getProjectSessionsPage(
projectId: string,
options: SessionPaginationOptions = {},
): Promise<ProjectSessionsPageApiView> {
const projectRow = projectsDb.getProjectById(projectId);
if (!projectRow) {
throw new AppError(`Project "${projectId}" was not found.`, {
code: 'PROJECT_NOT_FOUND',
statusCode: 404,
});
}
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
return {
projectId: projectRow.project_id,
sessions: sessionsPage.sessionsByProvider.claude,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
sessionMeta: {
hasMore: sessionsPage.hasMore,
total: sessionsPage.total,
},
};
}

View File

@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import test from 'node:test';
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
import { AppError } from '@/shared/utils.js';
type TestDependencies = Parameters<typeof startCloneProject>[2];
function buildDependencies(overrides: Partial<NonNullable<TestDependencies>> = {}): NonNullable<TestDependencies> {
return {
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/root' }),
ensureDirectory: async () => undefined,
pathExists: async () => false,
removePath: async () => undefined,
getGithubTokenById: async () => ({ github_token: 'token-value' }),
spawnGitClone: () => {
throw new Error('spawnGitClone should be overridden in this test');
},
registerProject: async () => ({ project: { projectId: 'project-1' } }),
logError: () => undefined,
...overrides,
};
}
function createMockGitProcess() {
const emitter = new EventEmitter() as EventEmitter & {
stdout: PassThrough;
stderr: PassThrough;
kill: () => void;
};
emitter.stdout = new PassThrough();
emitter.stderr = new PassThrough();
emitter.kill = () => {
emitter.emit('close', null);
};
return emitter;
}
test('startCloneProject rejects when workspace path is missing', async () => {
await assert.rejects(
async () =>
startCloneProject(
{
workspacePath: '',
githubUrl: 'https://github.com/example/repo',
userId: 1,
},
{
onProgress: () => undefined,
onComplete: () => undefined,
},
buildDependencies(),
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'WORKSPACE_PATH_REQUIRED');
return true;
},
);
});
test('startCloneProject rejects when github URL is missing', async () => {
await assert.rejects(
async () =>
startCloneProject(
{
workspacePath: '/workspace/root',
githubUrl: '',
userId: 1,
},
{
onProgress: () => undefined,
onComplete: () => undefined,
},
buildDependencies(),
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'GITHUB_URL_REQUIRED');
return true;
},
);
});
test('startCloneProject rejects github URL values that begin with option prefixes', async () => {
await assert.rejects(
async () =>
startCloneProject(
{
workspacePath: '/workspace/root',
githubUrl: '--upload-pack=malicious',
userId: 1,
},
{
onProgress: () => undefined,
onComplete: () => undefined,
},
buildDependencies(),
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'INVALID_GITHUB_URL');
return true;
},
);
});
test('startCloneProject rejects when selected github token does not exist', async () => {
await assert.rejects(
async () =>
startCloneProject(
{
workspacePath: '/workspace/root',
githubUrl: 'https://github.com/example/repo',
githubTokenId: 12,
userId: 1,
},
{
onProgress: () => undefined,
onComplete: () => undefined,
},
buildDependencies({
getGithubTokenById: async () => null,
}),
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'GITHUB_TOKEN_NOT_FOUND');
return true;
},
);
});
test('startCloneProject completes and emits complete payload when git exits successfully', async () => {
const gitProcess = createMockGitProcess();
const progressMessages: string[] = [];
let completePayload: { project: Record<string, unknown>; message: string } | null = null;
let capturedProjectPath = '';
let capturedCustomName = '';
const operation = await startCloneProject(
{
workspacePath: '/workspace/root',
githubUrl: 'https://github.com/example/repo.git',
userId: 1,
},
{
onProgress: (message) => {
progressMessages.push(message);
},
onComplete: (payload: { project: Record<string, unknown>; message: string }) => {
completePayload = payload;
},
},
buildDependencies({
spawnGitClone: () => gitProcess as any,
registerProject: async (projectPath, customName) => {
capturedProjectPath = projectPath;
capturedCustomName = customName;
return { project: { projectId: 'project-1', path: projectPath } };
},
}),
);
gitProcess.emit('close', 0);
await operation.waitForCompletion;
assert.ok(progressMessages.some((message) => message.includes("Cloning into 'repo'")));
assert.equal(capturedCustomName, 'repo');
assert.equal(path.basename(capturedProjectPath), 'repo');
assert.notEqual(completePayload, null);
const resolvedCompletePayload = completePayload as unknown as {
project: Record<string, unknown>;
message: string;
};
assert.equal(resolvedCompletePayload.message, 'Repository cloned successfully');
assert.equal((resolvedCompletePayload.project.projectId as string) || '', 'project-1');
});

View File

@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createProject } from '@/modules/projects/services/project-management.service.js';
import { AppError } from '@/shared/utils.js';
const projectRow = {
project_id: 'project-1',
project_path: '/workspace/my-project',
custom_project_name: 'my-project',
isStarred: 0,
isArchived: 0,
};
test('createProject throws when project path is missing', async () => {
await assert.rejects(
async () => createProject({ projectPath: '' }),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'PROJECT_PATH_REQUIRED');
assert.equal(error.statusCode, 400);
return true;
},
);
});
test('createProject throws when path validation fails', async () => {
await assert.rejects(
async () =>
createProject(
{ projectPath: '/invalid/path' },
{
validatePath: async () => ({ valid: false, error: 'blocked path' }),
ensureWorkspaceDirectory: async () => undefined,
persistProjectPath: () => ({ outcome: 'created', project: projectRow }),
getProjectByPath: () => projectRow,
},
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'INVALID_PROJECT_PATH');
assert.equal(error.statusCode, 400);
assert.equal(error.details, 'blocked path');
return true;
},
);
});
test('createProject throws conflict when active project path already exists', async () => {
await assert.rejects(
async () =>
createProject(
{ projectPath: '/workspace/my-project' },
{
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
ensureWorkspaceDirectory: async () => undefined,
persistProjectPath: () => ({ outcome: 'active_conflict', project: projectRow }),
getProjectByPath: () => projectRow,
},
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'PROJECT_ALREADY_EXISTS');
assert.equal(error.statusCode, 409);
assert.equal(error.details, 'Project path already exists: /workspace/my-project');
return true;
},
);
});
test('createProject falls back to directory name when custom name is not provided', async () => {
let capturedCustomName: string | null = null;
const result = await createProject(
{ projectPath: '/workspace/my-project', customName: '' },
{
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
ensureWorkspaceDirectory: async () => undefined,
persistProjectPath: (_projectPath, customName) => {
capturedCustomName = customName;
return {
outcome: 'created',
project: {
...projectRow,
custom_project_name: customName,
},
};
},
getProjectByPath: () => projectRow,
},
);
assert.equal(capturedCustomName, 'my-project');
assert.equal(result.outcome, 'created');
assert.equal(result.project.displayName, 'my-project');
});
test('createProject returns archived reuse outcome when archived row is reused', async () => {
const result = await createProject(
{ projectPath: '/workspace/my-project' },
{
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
ensureWorkspaceDirectory: async () => undefined,
persistProjectPath: () => ({
outcome: 'reactivated_archived',
project: {
...projectRow,
isArchived: 1,
},
}),
getProjectByPath: () => projectRow,
},
);
assert.equal(result.outcome, 'reactivated_archived');
assert.equal(result.project.isArchived, true);
});

View File

@@ -0,0 +1,123 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { projectsDb } from '@/modules/database/index.js';
import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
import { AppError } from '@/shared/utils.js';
type ProjectRow = {
project_id: string;
project_path: string;
custom_project_name: string | null;
isStarred: number;
isArchived: number;
};
test('toggleProjectStar throws when projectId is missing', () => {
assert.throws(
() => toggleProjectStar(' '),
(error: unknown) =>
error instanceof AppError
&& error.code === 'PROJECT_ID_REQUIRED'
&& error.statusCode === 400,
);
});
test('toggleProjectStar throws when project does not exist', () => {
const originalGetProjectById = projectsDb.getProjectById;
try {
projectsDb.getProjectById = () => null;
assert.throws(
() => toggleProjectStar('project-1'),
(error: unknown) =>
error instanceof AppError
&& error.code === 'PROJECT_NOT_FOUND'
&& error.statusCode === 404,
);
} finally {
projectsDb.getProjectById = originalGetProjectById;
}
});
test('toggleProjectStar flips star state and persists it', () => {
const originalGetProjectById = projectsDb.getProjectById;
const originalUpdateProjectIsStarredById = projectsDb.updateProjectIsStarredById;
let capturedProjectId = '';
let capturedState = false;
try {
projectsDb.getProjectById = () =>
({
project_id: 'project-1',
project_path: '/workspace/project-1',
custom_project_name: 'project-1',
isStarred: 0,
isArchived: 0,
}) as ProjectRow;
projectsDb.updateProjectIsStarredById = (projectId: string, isStarred: boolean) => {
capturedProjectId = projectId;
capturedState = isStarred;
};
const result = toggleProjectStar('project-1');
assert.equal(result.isStarred, true);
assert.equal(capturedProjectId, 'project-1');
assert.equal(capturedState, true);
} finally {
projectsDb.getProjectById = originalGetProjectById;
projectsDb.updateProjectIsStarredById = originalUpdateProjectIsStarredById;
}
});
test('applyLegacyStarredProjectIds stars only valid, unstarred projects', () => {
const originalGetProjectById = projectsDb.getProjectById;
const originalUpdateProjectIsStarredById = projectsDb.updateProjectIsStarredById;
const updatedProjectIds: string[] = [];
try {
projectsDb.getProjectById = (projectId: string) => {
if (projectId === 'project-a') {
return {
project_id: 'project-a',
project_path: '/workspace/project-a',
custom_project_name: 'A',
isStarred: 0,
isArchived: 0,
} as ProjectRow;
}
if (projectId === 'project-b') {
return {
project_id: 'project-b',
project_path: '/workspace/project-b',
custom_project_name: 'B',
isStarred: 1,
isArchived: 0,
} as ProjectRow;
}
return null;
};
projectsDb.updateProjectIsStarredById = (projectId: string) => {
updatedProjectIds.push(projectId);
};
const result = applyLegacyStarredProjectIds([
'project-a',
'project-b',
'missing-project',
'project-a',
'',
' ',
]);
assert.equal(result.updated, 1);
assert.deepEqual(updatedProjectIds, ['project-a']);
} finally {
projectsDb.getProjectById = originalGetProjectById;
projectsDb.updateProjectIsStarredById = originalUpdateProjectIsStarredById;
}
});

View File

@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getProjectTaskMaster,
getProjectTaskMasterById,
} from '@/modules/projects/services/projects-has-taskmaster.service.js';
import { AppError } from '@/shared/utils.js';
test('getProjectTaskMasterById returns null when project path is missing', async () => {
const result = await getProjectTaskMasterById('project-1', {
resolveProjectPathById: () => null,
detectTaskMasterFolder: async () => {
throw new Error('detectTaskMasterFolder should not be called when path is missing');
},
});
assert.equal(result, null);
});
test('getProjectTaskMasterById returns configured status when taskmaster exists with essential files', async () => {
const result = await getProjectTaskMasterById('project-1', {
resolveProjectPathById: () => '/workspace/project-1',
detectTaskMasterFolder: async () => ({
hasTaskmaster: true,
hasEssentialFiles: true,
metadata: {
taskCount: 3,
subtaskCount: 0,
completed: 1,
pending: 2,
inProgress: 0,
review: 0,
completionPercentage: 33,
lastModified: '2026-01-01T00:00:00.000Z',
},
}),
});
assert.ok(result);
assert.equal(result.projectId, 'project-1');
assert.equal(result.projectPath, '/workspace/project-1');
assert.equal(result.taskmaster.hasTaskmaster, true);
assert.equal(result.taskmaster.hasEssentialFiles, true);
assert.equal(result.taskmaster.status, 'configured');
assert.deepEqual(result.taskmaster.metadata, {
taskCount: 3,
subtaskCount: 0,
completed: 1,
pending: 2,
inProgress: 0,
review: 0,
completionPercentage: 33,
lastModified: '2026-01-01T00:00:00.000Z',
});
});
test('getProjectTaskMasterById returns not-configured status when taskmaster is missing', async () => {
const result = await getProjectTaskMasterById('project-1', {
resolveProjectPathById: () => '/workspace/project-1',
detectTaskMasterFolder: async () => ({
hasTaskmaster: false,
}),
});
assert.ok(result);
assert.equal(result.taskmaster.hasTaskmaster, false);
assert.equal(result.taskmaster.hasEssentialFiles, false);
assert.equal(result.taskmaster.status, 'not-configured');
assert.equal(result.taskmaster.metadata, null);
});
test('getProjectTaskMaster throws when project id is missing', async () => {
await assert.rejects(
async () =>
getProjectTaskMaster('', async () => ({
projectId: 'project-1',
projectPath: '/workspace/project-1',
taskmaster: {
hasTaskmaster: true,
hasEssentialFiles: true,
metadata: null,
status: 'configured',
},
})),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'PROJECT_ID_REQUIRED');
assert.equal(error.statusCode, 400);
return true;
},
);
});
test('getProjectTaskMaster throws when project does not exist', async () => {
await assert.rejects(
async () => getProjectTaskMaster('project-that-does-not-exist', async () => null),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'PROJECT_NOT_FOUND');
assert.equal(error.statusCode, 404);
return true;
},
);
});

View File

@@ -0,0 +1,4 @@
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';

View File

@@ -0,0 +1,123 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type ClaudeCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class ClaudeProviderAuth implements IProviderAuth {
/**
* Checks whether the Claude Code CLI is available on this host.
*/
private checkInstalled(): boolean {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Claude installation and credential status using Claude Code's auth priority.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'claude',
authenticated: false,
email: null,
method: null,
error: 'Claude Code CLI is not installed',
};
}
const credentials = await this.checkCredentials();
return {
installed,
provider: 'claude',
authenticated: credentials.authenticated,
email: credentials.authenticated ? credentials.email || 'Authenticated' : credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Reads Claude settings env values that the CLI can use even when the server process env is empty.
*/
private async loadSettingsEnv(): Promise<Record<string, unknown>> {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await readFile(settingsPath, 'utf8');
const settings = readObjectRecord(JSON.parse(content));
return readObjectRecord(settings?.env) ?? {};
} catch {
return {};
}
}
/**
* Checks Claude credentials in the same priority order used by Claude Code.
*/
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
if (process.env.ANTHROPIC_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const settingsEnv = await this.loadSettingsEnv();
if (readOptionalString(settingsEnv.ANTHROPIC_API_KEY)) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
if (readOptionalString(settingsEnv.ANTHROPIC_AUTH_TOKEN)) {
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
}
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await readFile(credPath, 'utf8');
const creds = readObjectRecord(JSON.parse(content)) ?? {};
const oauth = readObjectRecord(creds.claudeAiOauth);
const accessToken = readOptionalString(oauth?.accessToken);
if (accessToken) {
const expiresAt = typeof oauth?.expiresAt === 'number' ? oauth.expiresAt : undefined;
const email = readOptionalString(creds.email) ?? readOptionalString(creds.user) ?? null;
if (!expiresAt || Date.now() < expiresAt) {
return {
authenticated: true,
email,
method: 'credentials_file',
};
}
return {
authenticated: false,
email,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login',
};
}
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}
}

View File

@@ -0,0 +1,135 @@
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readJsonConfig,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
writeJsonConfig,
} from '@/shared/utils.js';
export class ClaudeMcpProvider extends McpProvider {
constructor() {
super('claude', ['user', 'local', 'project'], ['stdio', 'http', 'sse']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
if (scope === 'project') {
const filePath = path.join(workspacePath, '.mcp.json');
const config = await readJsonConfig(filePath);
return readObjectRecord(config.mcpServers) ?? {};
}
const filePath = path.join(os.homedir(), '.claude.json');
const config = await readJsonConfig(filePath);
if (scope === 'user') {
return readObjectRecord(config.mcpServers) ?? {};
}
const projects = readObjectRecord(config.projects) ?? {};
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
return readObjectRecord(projectConfig.mcpServers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
if (scope === 'project') {
const filePath = path.join(workspacePath, '.mcp.json');
const config = await readJsonConfig(filePath);
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
return;
}
const filePath = path.join(os.homedir(), '.claude.json');
const config = await readJsonConfig(filePath);
if (scope === 'user') {
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
return;
}
const projects = readObjectRecord(config.projects) ?? {};
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
projectConfig.mcpServers = servers;
projects[workspacePath] = projectConfig;
config.projects = projects;
await writeJsonConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
type: 'stdio',
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: input.transport,
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'claude',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
};
}
if (typeof config.url === 'string') {
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
return {
provider: 'claude',
name,
scope,
transport,
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -0,0 +1,110 @@
import os from 'node:os';
import path from 'node:path';
import { sessionsDb } from '@/modules/database/index.js';
import {
buildLookupMap,
extractFirstValidJsonlData,
findFilesRecursivelyCreatedAfter,
normalizeSessionName,
readFileTimestamps,
} from '@/shared/utils.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
type ParsedSession = {
sessionId: string;
projectPath: string;
sessionName?: string;
};
/**
* Session indexer for Claude transcript artifacts.
*/
export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'claude' as const;
private readonly claudeHome = path.join(os.homedir(), '.claude');
/**
* Scans ~/.claude/projects and upserts discovered sessions into DB.
*/
async synchronize(since?: Date): Promise<number> {
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
const files = await findFilesRecursivelyCreatedAfter(
path.join(this.claudeHome, 'projects'),
'.jsonl',
since ?? null
);
let processed = 0;
for (const filePath of files) {
const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
continue;
}
const timestamps = await readFileTimestamps(filePath);
sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
processed += 1;
}
return processed;
}
/**
* Parses and upserts one Claude session JSONL file.
*/
async synchronizeFile(filePath: string): Promise<string | null> {
if (!filePath.endsWith('.jsonl')) {
return null;
}
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
return null;
}
const timestamps = await readFileTimestamps(filePath);
return sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
}
/**
* Extracts session metadata from one Claude JSONL session file.
*/
private async processSessionFile(
filePath: string,
nameMap: Map<string, string>
): Promise<ParsedSession | null> {
return extractFirstValidJsonlData(filePath, (rawData) => {
const data = rawData as Record<string, unknown>;
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined;
if (!sessionId || !projectPath) {
return null;
}
return {
sessionId,
projectPath,
sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Claude Session'),
};
});
}
}

View File

@@ -0,0 +1,475 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import readline from 'node:readline';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
import { sessionsDb } from '@/modules/database/index.js';
const PROVIDER = 'claude';
type ClaudeToolResult = {
content: unknown;
isError: boolean;
subagentTools?: unknown;
toolUseResult?: unknown;
};
type ClaudeHistoryResult =
| AnyRecord[]
| {
messages?: AnyRecord[];
total?: number;
hasMore?: boolean;
};
type ClaudeHistoryMessagesResult =
| AnyRecord[]
| {
messages: AnyRecord[];
total: number;
hasMore: boolean;
offset?: number;
limit?: number | null;
};
async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
const tools: AnyRecord[] = [];
try {
const fileStream = fs.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line.trim()) {
continue;
}
try {
const entry = JSON.parse(line) as AnyRecord;
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
for (const part of entry.message.content as AnyRecord[]) {
if (part.type === 'tool_use') {
tools.push({
toolId: part.id,
toolName: part.name,
toolInput: part.input,
timestamp: entry.timestamp,
});
}
}
}
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
for (const part of entry.message.content as AnyRecord[]) {
if (part.type !== 'tool_result') {
continue;
}
const tool = tools.find((candidate) => candidate.toolId === part.tool_use_id);
if (!tool) {
continue;
}
tool.toolResult = {
content: typeof part.content === 'string'
? part.content
: Array.isArray(part.content)
? part.content
.map((contentPart: AnyRecord) => contentPart?.text || '')
.join('\n')
: JSON.stringify(part.content),
isError: Boolean(part.is_error),
};
}
}
} catch {
// Skip malformed lines that can happen during concurrent writes.
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Error parsing agent file ${filePath}:`, message);
}
return tools;
}
async function getSessionMessages(
sessionId: string,
limit: number | null,
offset: number,
): Promise<ClaudeHistoryMessagesResult> {
try {
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
if (!jsonLPath) {
return { messages: [], total: 0, hasMore: false };
}
const projectDir = path.dirname(jsonLPath);
const files = await fsp.readdir(projectDir);
const agentFiles = files.filter((file) => file.endsWith('.jsonl') && file.startsWith('agent-'));
const messages: AnyRecord[] = [];
const agentToolsCache = new Map<string, AnyRecord[]>();
const fileStream = fs.createReadStream(jsonLPath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line.trim()) {
continue;
}
try {
const entry = JSON.parse(line) as AnyRecord;
if (entry.sessionId === sessionId) {
messages.push(entry);
}
} catch {
// Skip malformed JSONL lines that can happen during concurrent writes.
}
}
const agentIds = new Set<string>();
for (const message of messages) {
const agentId = message.toolUseResult?.agentId;
if (agentId) {
agentIds.add(String(agentId));
}
}
for (const agentId of agentIds) {
const agentFileName = `agent-${agentId}.jsonl`;
if (!agentFiles.includes(agentFileName)) {
continue;
}
const agentFilePath = path.join(projectDir, agentFileName);
const tools = await parseAgentTools(agentFilePath);
agentToolsCache.set(agentId, tools);
}
for (const message of messages) {
const agentId = message.toolUseResult?.agentId;
if (!agentId) {
continue;
}
const agentTools = agentToolsCache.get(String(agentId));
if (agentTools && agentTools.length > 0) {
message.subagentTools = agentTools;
}
}
const sortedMessages = messages.sort(
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
);
const total = sortedMessages.length;
if (limit === null) {
return sortedMessages;
}
const startIndex = Math.max(0, total - offset - limit);
const endIndex = total - offset;
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
const hasMore = startIndex > 0;
return {
messages: paginatedMessages,
total,
hasMore,
offset,
limit,
};
} catch (error) {
console.error(`Error reading messages for session ${sessionId}:`, error);
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
}
}
/**
* Claude writes internal command and system reminder entries into history.
* Those are useful for the CLI but should not appear in the user-facing chat.
*/
const INTERNAL_CONTENT_PREFIXES = [
'<command-name>',
'<command-message>',
'<command-args>',
'<local-command-stdout>',
'<system-reminder>',
'Caveat:',
'This session is being continued from a previous',
'[Request interrupted',
] as const;
function isInternalContent(content: string): boolean {
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
}
export class ClaudeSessionsProvider implements IProviderSessions {
/**
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
* message shape consumed by REST and WebSocket clients.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (!raw) {
return [];
}
if (raw.type === 'content_block_delta' && raw.delta?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
}
if (raw.type === 'content_block_stop') {
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
}
const messages: NormalizedMessage[] = [];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('claude');
if (raw.message?.role === 'user' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
const part = raw.message.content[partIndex];
if (part.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: `${baseId}_tr_${part.tool_use_id}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id,
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
}));
} else if (part.type === 'text') {
const text = part.text || '';
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
}
if (messages.length === 0) {
const textParts = raw.message.content
.filter((part: AnyRecord) => part.type === 'text')
.map((part: AnyRecord) => part.text)
.filter(Boolean)
.join('\n');
if (textParts && !isInternalContent(textParts)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: textParts,
}));
}
}
} else if (typeof raw.message.content === 'string') {
const text = raw.message.content;
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
return messages;
}
if (raw.type === 'thinking' && raw.message?.content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message.content,
}));
return messages;
}
if (raw.type === 'tool_use' && raw.toolName) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName,
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
}));
return messages;
}
if (raw.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: false,
}));
return messages;
}
if (raw.message?.role === 'assistant' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
let partIndex = 0;
for (const part of raw.message.content) {
if (part.type === 'text' && part.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: part.text,
}));
} else if (part.type === 'tool_use') {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id,
}));
} else if (part.type === 'thinking' && part.thinking) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.thinking,
}));
}
partIndex++;
}
} else if (typeof raw.message.content === 'string') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message.content,
}));
}
return messages;
}
return messages;
}
/**
* Loads Claude JSONL history for a project/session and returns normalized
* messages, preserving the existing pagination behavior from projects.js.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
let result: ClaudeHistoryResult;
try {
result = await getSessionMessages(sessionId, limit, offset);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const toolResultMap = new Map<string, ClaudeToolResult>();
for (const raw of rawMessages) {
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result' && part.tool_use_id) {
toolResultMap.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
});
}
}
}
}
const normalized: NormalizedMessage[] = [];
for (const raw of rawMessages) {
normalized.push(...this.normalizeMessage(raw, sessionId));
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (!toolResult) {
continue;
}
msg.toolResult = {
content: typeof toolResult.content === 'string'
? toolResult.content
: JSON.stringify(toolResult.content),
isError: toolResult.isError,
toolUseResult: toolResult.toolUseResult,
};
msg.subagentTools = toolResult.subagentTools;
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
}
}

View File

@@ -0,0 +1,17 @@
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js';
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
export class ClaudeProvider extends AbstractProvider {
readonly mcp = new ClaudeMcpProvider();
readonly auth: IProviderAuth = new ClaudeProviderAuth();
readonly sessions: IProviderSessions = new ClaudeSessionsProvider();
readonly sessionSynchronizer: IProviderSessionSynchronizer = new ClaudeSessionSynchronizer();
constructor() {
super('claude');
}
}

View File

@@ -0,0 +1,100 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type CodexCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class CodexProviderAuth implements IProviderAuth {
/**
* Checks whether Codex is available to the server runtime.
*/
private checkInstalled(): boolean {
try {
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Codex SDK availability and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
const credentials = await this.checkCredentials();
return {
installed,
provider: 'codex',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Reads Codex auth.json and checks OAuth tokens or an API key fallback.
*/
private async checkCredentials(): Promise<CodexCredentialsStatus> {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await readFile(authPath, 'utf8');
const auth = readObjectRecord(JSON.parse(content)) ?? {};
const tokens = readObjectRecord(auth.tokens) ?? {};
const idToken = readOptionalString(tokens.id_token);
const accessToken = readOptionalString(tokens.access_token);
if (idToken || accessToken) {
return {
authenticated: true,
email: idToken ? this.readEmailFromIdToken(idToken) : 'Authenticated',
method: 'credentials_file',
};
}
if (readOptionalString(auth.OPENAI_API_KEY)) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
return { authenticated: false, email: null, method: null, error: 'No valid tokens found' };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return {
authenticated: false,
email: null,
method: null,
error: code === 'ENOENT' ? 'Codex not configured' : error instanceof Error ? error.message : 'Failed to read Codex auth',
};
}
}
/**
* Extracts the user email from a Codex id_token when a readable JWT payload exists.
*/
private readEmailFromIdToken(idToken: string): string {
try {
const parts = idToken.split('.');
if (parts.length >= 2) {
const payload = readObjectRecord(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')));
return readOptionalString(payload?.email) ?? readOptionalString(payload?.user) ?? 'Authenticated';
}
} catch {
// Fall back to a generic authenticated marker if the token payload is not readable.
}
return 'Authenticated';
}
}

View File

@@ -0,0 +1,135 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import TOML from '@iarna/toml';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
} from '@/shared/utils.js';
const readTomlConfig = async (filePath: string): Promise<Record<string, unknown>> => {
try {
const content = await readFile(filePath, 'utf8');
const parsed = TOML.parse(content) as Record<string, unknown>;
return readObjectRecord(parsed) ?? {};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {};
}
throw error;
}
};
const writeTomlConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
await mkdir(path.dirname(filePath), { recursive: true });
const toml = TOML.stringify(data as never);
await writeFile(filePath, toml, 'utf8');
};
export class CodexMcpProvider extends McpProvider {
constructor() {
super('codex', ['user', 'project'], ['stdio', 'http']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.codex', 'config.toml')
: path.join(workspacePath, '.codex', 'config.toml');
const config = await readTomlConfig(filePath);
return readObjectRecord(config.mcp_servers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.codex', 'config.toml')
: path.join(workspacePath, '.codex', 'config.toml');
const config = await readTomlConfig(filePath);
config.mcp_servers = servers;
await writeTomlConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
env_vars: input.envVars ?? [],
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
url: input.url,
bearer_token_env_var: input.bearerTokenEnvVar,
http_headers: input.headers ?? {},
env_http_headers: input.envHttpHeaders ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'codex',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
envVars: readStringArray(config.env_vars),
};
}
if (typeof config.url === 'string') {
return {
provider: 'codex',
name,
scope,
transport: 'http',
url: config.url,
headers: readStringRecord(config.http_headers),
bearerTokenEnvVar: readOptionalString(config.bearer_token_env_var),
envHttpHeaders: readStringRecord(config.env_http_headers),
};
}
return null;
}
}

View File

@@ -0,0 +1,119 @@
import os from 'node:os';
import path from 'node:path';
import { sessionsDb } from '@/modules/database/index.js';
import {
buildLookupMap,
extractFirstValidJsonlData,
findFilesRecursivelyCreatedAfter,
normalizeSessionName,
readFileTimestamps,
} from '@/shared/utils.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
type ParsedSession = {
sessionId: string;
projectPath: string;
sessionName?: string;
};
/**
* Session indexer for Codex transcript artifacts.
*/
export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'codex' as const;
private readonly codexHome = path.join(os.homedir(), '.codex');
/**
* Scans ~/.codex/sessions and upserts discovered sessions into DB.
*/
async synchronize(since?: Date): Promise<number> {
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
const files = await findFilesRecursivelyCreatedAfter(
path.join(this.codexHome, 'sessions'),
'.jsonl',
since ?? null
);
let processed = 0;
for (const filePath of files) {
const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
continue;
}
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
if (existingSession) {
// If session name is untitled and we now have a name, update it
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
}
}
const timestamps = await readFileTimestamps(filePath);
sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
processed += 1;
}
return processed;
}
/**
* Parses and upserts one Codex session JSONL file.
*/
async synchronizeFile(filePath: string): Promise<string | null> {
if (!filePath.endsWith('.jsonl')) {
return null;
}
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
return null;
}
const timestamps = await readFileTimestamps(filePath);
return sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
}
/**
* Extracts session metadata from one Codex JSONL session file.
*/
private async processSessionFile(
filePath: string,
nameMap: Map<string, string>
): Promise<ParsedSession | null> {
return extractFirstValidJsonlData(filePath, (rawData) => {
const data = rawData as Record<string, unknown>;
const payload = data.payload as Record<string, unknown> | undefined;
const sessionId = typeof payload?.id === 'string' ? payload.id : undefined;
const projectPath = typeof payload?.cwd === 'string' ? payload.cwd : undefined;
if (!sessionId || !projectPath) {
return null;
}
return {
sessionId,
projectPath,
sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Codex Session'),
};
});
}
}

View File

@@ -0,0 +1,564 @@
import fsSync from 'node:fs';
import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'codex';
type CodexHistoryResult =
| AnyRecord[]
| {
messages?: AnyRecord[];
total?: number;
hasMore?: boolean;
offset?: number;
limit?: number | null;
tokenUsage?: unknown;
};
function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boolean {
if (!payload || payload.type !== 'user_message') {
return false;
}
if (payload.kind && payload.kind !== 'plain') {
return false;
}
return typeof payload.message === 'string' && payload.message.trim().length > 0;
}
function extractCodexTextContent(content: unknown): string {
if (!Array.isArray(content)) {
return typeof content === 'string' ? content : '';
}
return content
.map((item) => {
if (!item || typeof item !== 'object') {
return '';
}
const record = item as AnyRecord;
if (
(record.type === 'input_text' || record.type === 'output_text' || record.type === 'text')
&& typeof record.text === 'string'
) {
return record.text;
}
return '';
})
.filter(Boolean)
.join('\n');
}
async function getCodexSessionMessages(
sessionId: string,
limit: number | null = null,
offset = 0,
): Promise<CodexHistoryResult> {
try {
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
if (!sessionFilePath) {
console.warn(`Codex session file not found for session ${sessionId}`);
return { messages: [], total: 0, hasMore: false };
}
const messages: AnyRecord[] = [];
let tokenUsage: AnyRecord | null = null;
const fileStream = fsSync.createReadStream(sessionFilePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line.trim()) {
continue;
}
try {
const entry = JSON.parse(line) as AnyRecord;
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const info = entry.payload.info as AnyRecord;
if (info.total_token_usage) {
const usage = info.total_token_usage as AnyRecord;
tokenUsage = {
used: usage.total_tokens || 0,
total: info.model_context_window || 200000,
};
}
}
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload as AnyRecord)) {
messages.push({
type: 'user',
timestamp: entry.timestamp,
message: {
role: 'user',
content: entry.payload.message,
},
});
}
if (
entry.type === 'response_item' &&
entry.payload?.type === 'message' &&
entry.payload.role === 'assistant'
) {
const textContent = extractCodexTextContent(entry.payload.content);
if (textContent.trim()) {
messages.push({
type: 'assistant',
timestamp: entry.timestamp,
message: {
role: 'assistant',
content: textContent,
},
});
}
}
if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
const summaryText = Array.isArray(entry.payload.summary)
? entry.payload.summary
.map((item: AnyRecord) => item?.text)
.filter(Boolean)
.join('\n')
: '';
if (summaryText.trim()) {
messages.push({
type: 'thinking',
timestamp: entry.timestamp,
message: {
role: 'assistant',
content: summaryText,
},
});
}
}
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
let toolName = entry.payload.name;
let toolInput = entry.payload.arguments;
if (toolName === 'shell_command') {
toolName = 'Bash';
try {
const args = JSON.parse(entry.payload.arguments) as AnyRecord;
toolInput = JSON.stringify({ command: args.command });
} catch {
// Keep original arguments when parsing fails.
}
}
messages.push({
type: 'tool_use',
timestamp: entry.timestamp,
toolName,
toolInput,
toolCallId: entry.payload.call_id,
});
}
if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
messages.push({
type: 'tool_result',
timestamp: entry.timestamp,
toolCallId: entry.payload.call_id,
output: entry.payload.output,
});
}
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
const toolName = entry.payload.name || 'custom_tool';
const input = entry.payload.input || '';
if (toolName === 'apply_patch') {
const fileMatch = String(input).match(/\*\*\* Update File: (.+)/);
const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
const lines = String(input).split('\n');
const oldLines: string[] = [];
const newLines: string[] = [];
for (const lineContent of lines) {
if (lineContent.startsWith('-') && !lineContent.startsWith('---')) {
oldLines.push(lineContent.slice(1));
} else if (lineContent.startsWith('+') && !lineContent.startsWith('+++')) {
newLines.push(lineContent.slice(1));
}
}
messages.push({
type: 'tool_use',
timestamp: entry.timestamp,
toolName: 'Edit',
toolInput: JSON.stringify({
file_path: filePath,
old_string: oldLines.join('\n'),
new_string: newLines.join('\n'),
}),
toolCallId: entry.payload.call_id,
});
} else {
messages.push({
type: 'tool_use',
timestamp: entry.timestamp,
toolName,
toolInput: input,
toolCallId: entry.payload.call_id,
});
}
}
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
messages.push({
type: 'tool_result',
timestamp: entry.timestamp,
toolCallId: entry.payload.call_id,
output: entry.payload.output || '',
});
}
} catch {
// Skip malformed lines.
}
}
messages.sort(
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
);
const total = messages.length;
if (limit !== null) {
const startIndex = Math.max(0, total - offset - limit);
const endIndex = total - offset;
const paginatedMessages = messages.slice(startIndex, endIndex);
const hasMore = startIndex > 0;
return {
messages: paginatedMessages,
total,
hasMore,
offset,
limit,
tokenUsage,
};
}
return { messages, tokenUsage };
} catch (error) {
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
return { messages: [], total: 0, hasMore: false };
}
}
export class CodexSessionsProvider implements IProviderSessions {
/**
* Normalizes a persisted Codex JSONL entry.
*
* Live Codex SDK events are transformed before they reach normalizeMessage(),
* while history entries already use a compact message/tool shape from projects.js.
*/
private normalizeHistoryEntry(raw: AnyRecord, sessionId: string | null): NormalizedMessage[] {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
if (raw.type === 'thinking' || raw.isReasoning) {
const thinkingContent = typeof raw.message?.content === 'string'
? raw.message.content
: '';
if (!thinkingContent.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: thinkingContent,
})];
}
if (raw.message?.role === 'user') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
: String(raw.message.content || '');
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content,
})];
}
if (raw.message?.role === 'assistant') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
: '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content,
})];
}
if (raw.type === 'tool_use' || raw.toolName) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName || 'Unknown',
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: Boolean(raw.isError),
})];
}
return [];
}
/**
* Normalizes either a Codex history entry or a transformed live SDK event.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (!raw) {
return [];
}
if (raw.message?.role) {
return this.normalizeHistoryEntry(raw, sessionId);
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
if (raw.type === 'item') {
switch (raw.itemType) {
case 'agent_message':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message?.content || '',
})];
case 'reasoning':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
case 'command_execution':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'Bash',
toolInput: { command: raw.command },
toolId: baseId,
output: raw.output,
exitCode: raw.exitCode,
status: raw.status,
})];
case 'file_change':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'FileChanges',
toolInput: raw.changes,
toolId: baseId,
status: raw.status,
})];
case 'mcp_tool_call':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool || 'MCP',
toolInput: raw.arguments,
toolId: baseId,
server: raw.server,
result: raw.result,
error: raw.error,
status: raw.status,
})];
case 'web_search':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'WebSearch',
toolInput: { query: raw.query },
toolId: baseId,
})];
case 'todo_list':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'TodoList',
toolInput: { items: raw.items },
toolId: baseId,
})];
case 'error':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.message?.content || 'Unknown error',
})];
default:
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.itemType || 'Unknown',
toolInput: raw.item || raw,
toolId: baseId,
})];
}
}
if (raw.type === 'turn_complete') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'complete',
})];
}
if (raw.type === 'turn_failed') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error?.message || 'Turn failed',
})];
}
return [];
}
/**
* Loads Codex JSONL history and keeps token usage metadata when projects.js
* provides it.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
let result: CodexHistoryResult;
try {
result = await getCodexSessionMessages(sessionId, limit, offset);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
const normalized: NormalizedMessage[] = [];
for (const raw of rawMessages) {
normalized.push(...this.normalizeHistoryEntry(raw, sessionId));
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
tokenUsage,
};
}
}

View File

@@ -0,0 +1,17 @@
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js';
import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js';
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
export class CodexProvider extends AbstractProvider {
readonly mcp = new CodexMcpProvider();
readonly auth: IProviderAuth = new CodexProviderAuth();
readonly sessions: IProviderSessions = new CodexSessionsProvider();
readonly sessionSynchronizer: IProviderSessionSynchronizer = new CodexSessionSynchronizer();
constructor() {
super('codex');
}
}

View File

@@ -0,0 +1,143 @@
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
type CursorLoginStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class CursorProviderAuth implements IProviderAuth {
/**
* Checks whether the cursor-agent CLI is available on this host.
*/
private checkInstalled(): boolean {
try {
spawn.sync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Cursor CLI installation and login status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'cursor',
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI is not installed',
};
}
const login = await this.checkCursorLogin();
return {
installed,
provider: 'cursor',
authenticated: login.authenticated,
email: login.email,
method: login.method,
error: login.authenticated ? undefined : login.error || 'Not logged in',
};
}
/**
* Runs cursor-agent status and parses the login marker from stdout.
*/
private checkCursorLogin(): Promise<CursorLoginStatus> {
return new Promise((resolve) => {
let processCompleted = false;
let childProcess: ReturnType<typeof spawn> | undefined;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
childProcess?.kill();
resolve({
authenticated: false,
email: null,
method: null,
error: 'Command timeout',
});
}
}, 5000);
try {
childProcess = spawn('cursor-agent', ['status']);
} catch {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI not found or not installed',
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
childProcess.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) {
return;
}
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch?.[1]) {
resolve({ authenticated: true, email: emailMatch[1], method: 'cli' });
return;
}
if (stdout.includes('Logged in')) {
resolve({ authenticated: true, email: 'Logged in', method: 'cli' });
return;
}
resolve({ authenticated: false, email: null, method: null, error: 'Not logged in' });
return;
}
resolve({ authenticated: false, email: null, method: null, error: stderr || 'Not logged in' });
});
childProcess.on('error', () => {
if (processCompleted) {
return;
}
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI not found or not installed',
});
});
});
}
}

View File

@@ -0,0 +1,108 @@
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readJsonConfig,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
writeJsonConfig,
} from '@/shared/utils.js';
export class CursorMcpProvider extends McpProvider {
constructor() {
super('cursor', ['user', 'project'], ['stdio', 'http']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.cursor', 'mcp.json')
: path.join(workspacePath, '.cursor', 'mcp.json');
const config = await readJsonConfig(filePath);
return readObjectRecord(config.mcpServers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.cursor', 'mcp.json')
: path.join(workspacePath, '.cursor', 'mcp.json');
const config = await readJsonConfig(filePath);
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'cursor',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
};
}
if (typeof config.url === 'string') {
return {
provider: 'cursor',
name,
scope,
transport: 'http',
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -0,0 +1,176 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import {
extractFirstValidJsonlData,
findFilesRecursivelyCreatedAfter,
normalizeSessionName,
readFileTimestamps,
} from '@/shared/utils.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
type ParsedSession = {
sessionId: string;
projectPath: string;
sessionName?: string;
};
/**
* Returns directory entries or an empty list when the folder is missing.
*/
async function listDirectoryEntriesSafe(
directoryPath: string
): Promise<import('node:fs').Dirent[]> {
try {
return await fsp.readdir(directoryPath, { withFileTypes: true });
} catch {
return [];
}
}
/**
* Session indexer for Cursor transcript artifacts.
*/
export class CursorSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'cursor' as const;
private readonly cursorHome = path.join(os.homedir(), '.cursor');
/**
* Scans Cursor chats and upserts discovered sessions into DB.
*/
async synchronize(since?: Date): Promise<number> {
const projectsDir = path.join(this.cursorHome, 'projects');
const projectEntries = await listDirectoryEntriesSafe(projectsDir);
const seenProjectPaths = new Set<string>();
let processed = 0;
for (const entry of projectEntries) {
if (!entry.isDirectory()) {
continue;
}
const workerLogPath = path.join(projectsDir, entry.name, 'worker.log');
const projectPath = await this.extractProjectPathFromWorkerLog(workerLogPath);
if (!projectPath || seenProjectPaths.has(projectPath)) {
continue;
}
seenProjectPaths.add(projectPath);
const projectHash = this.md5(projectPath);
const chatsDir = path.join(this.cursorHome, 'chats', projectHash);
const files = await findFilesRecursivelyCreatedAfter(chatsDir, '.jsonl', since ?? null);
for (const filePath of files) {
const parsed = await this.processSessionFile(filePath);
if (!parsed) {
continue;
}
const timestamps = await readFileTimestamps(filePath);
sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
processed += 1;
}
}
return processed;
}
/**
* Parses and upserts one Cursor session JSONL file.
*/
async synchronizeFile(filePath: string): Promise<string | null> {
if (!filePath.endsWith('.jsonl')) {
return null;
}
const parsed = await this.processSessionFile(filePath);
if (!parsed) {
return null;
}
const timestamps = await readFileTimestamps(filePath);
return sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
}
/**
* Produces the same project hash Cursor uses in chat directory names.
*/
private md5(input: string): string {
return crypto.createHash('md5').update(input).digest('hex');
}
/**
* Extracts project path from Cursor worker.log.
*/
private async extractProjectPathFromWorkerLog(filePath: string): Promise<string | null> {
try {
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
const lineReader = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
for await (const line of lineReader) {
const match = line.match(/workspacePath=(.*)$/);
const projectPath = match?.[1]?.trim();
if (projectPath) {
lineReader.close();
fileStream.close();
return projectPath;
}
}
} catch {
// Missing worker logs are valid for partial or incomplete session data.
}
return null;
}
/**
* Extracts session metadata from one Cursor JSONL session file.
*/
private async processSessionFile(filePath: string): Promise<ParsedSession | null> {
const sessionId = path.basename(filePath, '.jsonl');
const grandparentDir = path.dirname(path.dirname(filePath));
const workerLogPath = path.join(grandparentDir, 'worker.log');
const projectPath = await this.extractProjectPathFromWorkerLog(workerLogPath);
if (!projectPath) {
return null;
}
return extractFirstValidJsonlData(filePath, (rawData) => {
const data = rawData as Record<string, any>;
if (data.role !== 'user') {
return null;
}
const text = typeof data.message?.content?.[0]?.text === 'string' ? data.message.content[0].text : '';
const firstLine = text.replace(/<\/?user_query>/g, '').trim().split('\n')[0];
return {
sessionId,
projectPath,
sessionName: normalizeSessionName(firstLine, 'Untitled Cursor Session'),
};
});
}
}

View File

@@ -0,0 +1,421 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'cursor';
type CursorDbBlob = {
rowid: number;
id: string;
data?: Buffer;
};
type CursorJsonBlob = CursorDbBlob & {
parsed: AnyRecord;
};
type CursorMessageBlob = {
id: string;
sequence: number;
rowid: number;
content: AnyRecord;
};
function sanitizeCursorSessionId(sessionId: string): string {
const normalized = sessionId.trim();
if (!normalized) {
throw new Error('Cursor session id is required.');
}
if (
normalized.includes('..')
|| normalized.includes(path.posix.sep)
|| normalized.includes(path.win32.sep)
|| normalized !== path.basename(normalized)
) {
throw new Error(`Invalid cursor session id "${sessionId}".`);
}
return normalized;
}
export class CursorSessionsProvider implements IProviderSessions {
/**
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
* order. Cursor history is stored as content-addressed blobs rather than JSONL.
*/
private async loadCursorBlobs(sessionId: string, projectPath: string): Promise<CursorMessageBlob[]> {
// Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
const { default: Database } = await import('better-sqlite3');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const safeSessionId = sanitizeCursorSessionId(sessionId);
const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
const resolvedBaseChatsPath = path.resolve(baseChatsPath);
const resolvedStoreDbPath = path.resolve(storeDbPath);
const relativeStorePath = path.relative(resolvedBaseChatsPath, resolvedStoreDbPath);
if (relativeStorePath.startsWith('..') || path.isAbsolute(relativeStorePath)) {
throw new Error(`Invalid cursor session path for "${sessionId}".`);
}
const db = new Database(resolvedStoreDbPath, { readonly: true, fileMustExist: true });
try {
const allBlobs = db.prepare<[], CursorDbBlob>('SELECT rowid, id, data FROM blobs').all();
const blobMap = new Map<string, CursorDbBlob>();
const parentRefs = new Map<string, string[]>();
const childRefs = new Map<string, string[]>();
const jsonBlobs: CursorJsonBlob[] = [];
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
if (blob.data && blob.data[0] === 0x7B) {
try {
const parsed = JSON.parse(blob.data.toString('utf8')) as AnyRecord;
jsonBlobs.push({ ...blob, parsed });
} catch {
// Cursor can include binary or partial blobs; only JSON blobs become messages.
}
}
}
for (const blob of allBlobs) {
if (!blob.data || blob.data[0] === 0x7B) {
continue;
}
const parents: string[] = [];
let i = 0;
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
for (const parentId of parents) {
if (!childRefs.has(parentId)) {
childRefs.set(parentId, []);
}
childRefs.get(parentId)?.push(blob.id);
}
}
}
const visited = new Set<string>();
const sorted: CursorDbBlob[] = [];
const visit = (nodeId: string): void => {
if (visited.has(nodeId)) {
return;
}
visited.add(nodeId);
for (const parentId of parentRefs.get(nodeId) || []) {
visit(parentId);
}
const blob = blobMap.get(nodeId);
if (blob) {
sorted.push(blob);
}
};
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) {
visit(blob.id);
}
}
for (const blob of allBlobs) {
visit(blob.id);
}
const messageOrder = new Map<string, number>();
let orderIndex = 0;
for (const blob of sorted) {
if (blob.data && blob.data[0] !== 0x7B) {
for (const jsonBlob of jsonBlobs) {
try {
const idBytes = Buffer.from(jsonBlob.id, 'hex');
if (blob.data.includes(idBytes) && !messageOrder.has(jsonBlob.id)) {
messageOrder.set(jsonBlob.id, orderIndex++);
}
} catch {
// Ignore malformed blob ids that cannot be decoded as hex.
}
}
}
}
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const aOrder = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const bOrder = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return aOrder !== bOrder ? aOrder - bOrder : a.rowid - b.rowid;
});
const messages: CursorMessageBlob[] = [];
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
const blob = sortedJsonBlobs[idx];
const parsed = blob.parsed;
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') {
continue;
}
messages.push({
id: blob.id,
sequence: idx + 1,
rowid: blob.rowid,
content: parsed,
});
}
return messages;
} finally {
db.close();
}
}
/**
* Normalizes live Cursor CLI NDJSON events. Persisted Cursor history is
* normalized from SQLite blobs in fetchHistory().
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (raw?.type === 'assistant' && raw.message?.content?.[0]?.text) {
return [createNormalizedMessage({
kind: 'stream_delta',
content: raw.message.content[0].text,
sessionId,
provider: PROVIDER,
})];
}
if (typeof rawMessage === 'string' && rawMessage.trim()) {
return [createNormalizedMessage({
kind: 'stream_delta',
content: rawMessage,
sessionId,
provider: PROVIDER,
})];
}
return [];
}
/**
* Fetches and paginates Cursor session history from its project-scoped store.db.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectPath = '', limit = null, offset = 0 } = options;
try {
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
const total = allNormalized.length;
if (limit !== null) {
const start = offset;
const page = limit === 0
? []
: allNormalized.slice(start, start + limit);
const hasMore = limit === 0
? start < total
: start + limit < total;
return {
messages: page,
total,
hasMore,
offset,
limit,
};
}
return {
messages: allNormalized,
total,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CursorProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
}
/**
* Converts Cursor SQLite message blobs into normalized messages and attaches
* matching tool results to their tool_use entries.
*/
private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
const messages: NormalizedMessage[] = [];
const toolUseMap = new Map<string, NormalizedMessage>();
const baseTime = Date.now();
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
const content = blob.content;
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
const baseId = blob.id || generateMessageId('cursor');
try {
if (!content?.role || !content?.content) {
if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') {
continue;
}
const role = content.message.role === 'user' ? 'user' : 'assistant';
let text = '';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
if (text?.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
}
continue;
}
if (content.role === 'system') {
continue;
}
if (content.role === 'tool') {
const toolItems = Array.isArray(content.content) ? content.content : [];
for (const item of toolItems) {
if (item?.type !== 'tool-result') {
continue;
}
const toolCallId = item.toolCallId || content.id;
messages.push(createNormalizedMessage({
id: `${baseId}_tr`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: toolCallId,
content: item.result || '',
isError: false,
}));
}
continue;
}
const role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
const part = content.content[partIdx];
if (part?.type === 'text' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: part.text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
} else if (part?.type === 'reasoning' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.text,
}));
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
const rawToolName = part.toolName || part.name || 'Unknown Tool';
const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName;
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
const message = createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: part.args || part.input,
toolId,
});
messages.push(message);
toolUseMap.set(toolId, message);
}
}
} else if (typeof content.content === 'string' && content.content.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: content.content,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
} catch (error) {
console.warn('Error normalizing cursor blob:', error);
}
}
for (const msg of messages) {
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
const toolUse = toolUseMap.get(msg.toolId);
if (toolUse) {
toolUse.toolResult = {
content: msg.content,
isError: msg.isError,
};
}
}
}
messages.sort((a, b) => {
if (a.sequence !== undefined && b.sequence !== undefined) {
return a.sequence - b.sequence;
}
if (a.rowid !== undefined && b.rowid !== undefined) {
return a.rowid - b.rowid;
}
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
return messages;
}
}

View File

@@ -0,0 +1,17 @@
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
import { CursorSessionSynchronizer } from '@/modules/providers/list/cursor/cursor-session-synchronizer.provider.js';
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
export class CursorProvider extends AbstractProvider {
readonly mcp = new CursorMcpProvider();
readonly auth: IProviderAuth = new CursorProviderAuth();
readonly sessions: IProviderSessions = new CursorSessionsProvider();
readonly sessionSynchronizer: IProviderSessionSynchronizer = new CursorSessionSynchronizer();
constructor() {
super('cursor');
}
}

View File

@@ -0,0 +1,151 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type GeminiCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class GeminiProviderAuth implements IProviderAuth {
/**
* Checks whether the Gemini CLI is available on this host.
*/
private checkInstalled(): boolean {
const cliPath = process.env.GEMINI_PATH || 'gemini';
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Gemini CLI installation and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'gemini',
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI is not installed',
};
}
const credentials = await this.checkCredentials();
return {
installed,
provider: 'gemini',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Checks Gemini credentials from API key env vars or local OAuth credential files.
*/
private async checkCredentials(): Promise<GeminiCredentialsStatus> {
if (process.env.GEMINI_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await readFile(credsPath, 'utf8');
const creds = readObjectRecord(JSON.parse(content)) ?? {};
const accessToken = readOptionalString(creds.access_token);
if (!accessToken) {
return {
authenticated: false,
email: null,
method: null,
error: 'No valid tokens found in oauth_creds',
};
}
const refreshToken = readOptionalString(creds.refresh_token);
const tokenInfo = await this.getTokenInfoEmail(accessToken);
if (tokenInfo.valid) {
return {
authenticated: true,
email: tokenInfo.email || 'OAuth Session',
method: 'credentials_file',
};
}
if (!refreshToken) {
return {
authenticated: false,
email: null,
method: 'credentials_file',
error: 'Access token invalid and no refresh token found',
};
}
return {
authenticated: true,
email: await this.getActiveAccountEmail() || 'OAuth Session',
method: 'credentials_file',
};
} catch {
return {
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI not configured',
};
}
}
/**
* Validates a Gemini OAuth access token and returns an email when Google reports one.
*/
private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> {
try {
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
if (!tokenRes.ok) {
return { valid: false, email: null };
}
const tokenInfo = readObjectRecord(await tokenRes.json());
return {
valid: true,
email: readOptionalString(tokenInfo?.email) ?? null,
};
} catch {
return { valid: false, email: null };
}
}
/**
* Reads Gemini's active local Google account as an offline fallback for display.
*/
private async getActiveAccountEmail(): Promise<string | null> {
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await readFile(accPath, 'utf8');
const accounts = readObjectRecord(JSON.parse(accContent));
return readOptionalString(accounts?.active) ?? null;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,110 @@
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readJsonConfig,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
writeJsonConfig,
} from '@/shared/utils.js';
export class GeminiMcpProvider extends McpProvider {
constructor() {
super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.gemini', 'settings.json')
: path.join(workspacePath, '.gemini', 'settings.json');
const config = await readJsonConfig(filePath);
return readObjectRecord(config.mcpServers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.gemini', 'settings.json')
: path.join(workspacePath, '.gemini', 'settings.json');
const config = await readJsonConfig(filePath);
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: input.transport,
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'gemini',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
};
}
if (typeof config.url === 'string') {
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
return {
provider: 'gemini',
name,
scope,
transport,
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -0,0 +1,401 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import {
findFilesRecursivelyCreatedAfter,
normalizeProjectPath,
normalizeSessionName,
readFileTimestamps,
} from '@/shared/utils.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
import type { AnyRecord } from '@/shared/types.js';
type ParsedSession = {
sessionId: string;
projectPath: string;
sessionName?: string;
};
type GeminiJsonlMetadata = {
sessionId: string;
projectPath?: string;
projectHash?: string;
firstUserMessage?: string;
};
/**
* Session indexer for Gemini transcript artifacts.
*/
export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'gemini' as const;
private readonly geminiHome = path.join(os.homedir(), '.gemini');
/**
* Scans Gemini legacy JSON and new JSONL artifacts and upserts sessions into DB.
*/
async synchronize(since?: Date): Promise<number> {
const projectHashLookup = this.buildProjectHashLookup();
const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
path.join(this.geminiHome, 'sessions'),
'.json',
since ?? null
);
const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
path.join(this.geminiHome, 'tmp'),
'.json',
since ?? null
);
const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
path.join(this.geminiHome, 'sessions'),
'.jsonl',
since ?? null
);
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
path.join(this.geminiHome, 'tmp'),
'.jsonl',
since ?? null
);
// Process legacy JSON first, then JSONL. If both exist for a session id,
// the JSONL artifact becomes the canonical jsonl_path via upsert.
const files = [
...legacySessionFiles,
...legacyTempFiles,
...jsonlSessionFiles,
...jsonlTempFiles,
];
let processed = 0;
for (const filePath of files) {
if (this.shouldSkipTempArtifact(filePath)) {
continue;
}
const parsed = filePath.endsWith('.jsonl')
? await this.processJsonlSessionFile(filePath, projectHashLookup)
: await this.processLegacySessionFile(filePath);
if (!parsed) {
continue;
}
const timestamps = await readFileTimestamps(filePath);
sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
processed += 1;
}
return processed;
}
/**
* Parses and upserts one Gemini legacy JSON or JSONL artifact.
*/
async synchronizeFile(filePath: string): Promise<string | null> {
if (!filePath.endsWith('.json') && !filePath.endsWith('.jsonl')) {
return null;
}
if (this.shouldSkipTempArtifact(filePath)) {
return null;
}
const parsed = filePath.endsWith('.jsonl')
? await this.processJsonlSessionFile(filePath, this.buildProjectHashLookup())
: await this.processLegacySessionFile(filePath);
if (!parsed) {
return null;
}
const timestamps = await readFileTimestamps(filePath);
return sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
}
/**
* Extracts session metadata from one Gemini legacy JSON artifact.
*/
private async processLegacySessionFile(filePath: string): Promise<ParsedSession | null> {
try {
const content = await readFile(filePath, 'utf8');
const data = JSON.parse(content) as AnyRecord;
const sessionId =
typeof data.sessionId === 'string'
? data.sessionId
: typeof data.id === 'string'
? data.id
: undefined;
if (!sessionId) {
return null;
}
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
const projectPath = typeof data.projectPath === 'string' && data.projectPath.trim().length > 0
? data.projectPath
: workspaceProjectPath;
if (!projectPath) {
return null;
}
const messages = Array.isArray(data.messages) ? data.messages : [];
const firstMessage = messages[0] as AnyRecord | undefined;
let rawName: string | undefined;
if (Array.isArray(firstMessage?.content) && typeof firstMessage.content[0]?.text === 'string') {
rawName = firstMessage.content[0].text;
} else if (typeof firstMessage?.content === 'string') {
rawName = firstMessage.content;
}
return {
sessionId,
projectPath,
sessionName: normalizeSessionName(rawName, 'New Gemini Chat'),
};
} catch {
return null;
}
}
/**
* Extracts session metadata from one Gemini JSONL artifact.
*/
private async processJsonlSessionFile(
filePath: string,
projectHashLookup: Map<string, string>
): Promise<ParsedSession | null> {
const metadata = await this.extractJsonlMetadata(filePath);
if (!metadata) {
return null;
}
let projectPath = typeof metadata.projectPath === 'string' ? metadata.projectPath.trim() : '';
if (!projectPath) {
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
if (workspaceProjectPath) {
projectPath = workspaceProjectPath;
}
}
if (!projectPath && typeof metadata.projectHash === 'string') {
projectPath = projectHashLookup.get(metadata.projectHash.trim().toLowerCase()) ?? '';
}
if (!projectPath) {
return null;
}
// Once we resolve a project hash/path pair, keep it in-memory for this sync run.
if (typeof metadata.projectHash === 'string' && metadata.projectHash.trim()) {
projectHashLookup.set(metadata.projectHash.trim().toLowerCase(), projectPath);
}
return {
sessionId: metadata.sessionId,
projectPath,
sessionName: normalizeSessionName(metadata.firstUserMessage, 'New Gemini Chat'),
};
}
/**
* Reads first useful metadata from Gemini JSONL files.
*/
private async extractJsonlMetadata(filePath: string): Promise<GeminiJsonlMetadata | null> {
try {
const content = await readFile(filePath, 'utf8');
const lines = content.split('\n');
let sessionId: string | undefined;
let projectPath: string | undefined;
let projectHash: string | undefined;
let firstUserMessage: string | undefined;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let parsed: AnyRecord;
try {
parsed = JSON.parse(trimmed) as AnyRecord;
} catch {
continue;
}
if (!sessionId && typeof parsed.sessionId === 'string') {
sessionId = parsed.sessionId;
}
if (!projectPath && typeof parsed.projectPath === 'string') {
projectPath = parsed.projectPath;
}
if (!projectHash && typeof parsed.projectHash === 'string') {
projectHash = parsed.projectHash;
}
if (!firstUserMessage && parsed.type === 'user') {
firstUserMessage = this.extractGeminiTextContent(parsed.content);
}
if (sessionId && (projectPath || projectHash) && firstUserMessage) {
break;
}
}
if (!sessionId) {
return null;
}
return {
sessionId,
projectPath,
projectHash,
firstUserMessage,
};
} catch {
return null;
}
}
/**
* Tries to resolve project root from Gemini tmp chat workspaces.
*/
private async resolveProjectPathFromChatWorkspace(filePath: string): Promise<string> {
if (!filePath.includes(`${path.sep}chats${path.sep}`)) {
return '';
}
const chatsDir = path.dirname(filePath);
const workspaceDir = path.dirname(chatsDir);
const projectRootPath = path.join(workspaceDir, '.project_root');
try {
const rootContent = await readFile(projectRootPath, 'utf8');
return rootContent.trim();
} catch {
return '';
}
}
/**
* Builds a hash->path lookup for Gemini JSONL metadata that stores projectHash.
*/
private buildProjectHashLookup(): Map<string, string> {
const lookup = new Map<string, string>();
const knownPaths = new Set<string>();
for (const project of projectsDb.getProjectPaths()) {
if (typeof project.project_path === 'string' && project.project_path.trim()) {
knownPaths.add(project.project_path.trim());
}
}
for (const session of sessionsDb.getAllSessions()) {
if (session.provider === this.provider && typeof session.project_path === 'string' && session.project_path.trim()) {
knownPaths.add(session.project_path.trim());
}
}
for (const knownPath of knownPaths) {
this.addProjectHashCandidates(lookup, knownPath);
}
return lookup;
}
/**
* Adds likely Gemini hash variants for one project path.
*/
private addProjectHashCandidates(lookup: Map<string, string>, projectPath: string): void {
const trimmed = projectPath.trim();
if (!trimmed) {
return;
}
const normalized = normalizeProjectPath(trimmed);
const resolved = path.resolve(trimmed);
const resolvedNormalized = normalizeProjectPath(resolved);
const candidates = new Set<string>([
trimmed,
normalized,
resolved,
resolvedNormalized,
]);
if (process.platform === 'win32') {
for (const candidate of [...candidates]) {
candidates.add(candidate.toLowerCase());
}
}
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const hash = this.sha256(candidate);
if (!lookup.has(hash)) {
lookup.set(hash, trimmed);
}
}
}
/**
* Returns first user text from Gemini content payload shapes.
*/
private extractGeminiTextContent(content: unknown): string | undefined {
if (typeof content === 'string' && content.trim().length > 0) {
return content;
}
if (!Array.isArray(content)) {
return undefined;
}
for (const part of content) {
if (typeof part === 'string' && part.trim().length > 0) {
return part;
}
if (part && typeof part === 'object' && typeof (part as AnyRecord).text === 'string') {
const text = (part as AnyRecord).text;
if (text.trim().length > 0) {
return text;
}
}
}
return undefined;
}
/**
* Keeps tmp scanning scoped to chat artifacts only.
*/
private shouldSkipTempArtifact(filePath: string): boolean {
return (
filePath.startsWith(path.join(this.geminiHome, 'tmp'))
&& !filePath.includes(`${path.sep}chats${path.sep}`)
);
}
private sha256(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex');
}
}

View File

@@ -0,0 +1,541 @@
import fsSync from 'node:fs';
import fs from 'node:fs/promises';
import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'gemini';
type GeminiHistoryResult = {
messages: AnyRecord[];
tokenUsage?: unknown;
};
function mapGeminiRole(value: unknown): 'user' | 'assistant' | null {
if (value === 'user') {
return 'user';
}
if (value === 'gemini' || value === 'assistant') {
return 'assistant';
}
return null;
}
function extractGeminiTextContent(content: unknown): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return '';
}
return content
.map((part) => {
if (typeof part === 'string') {
return part;
}
if (!part || typeof part !== 'object') {
return '';
}
const record = part as AnyRecord;
if (typeof record.text === 'string') {
return record.text;
}
return '';
})
.filter(Boolean)
.join('\n');
}
function extractGeminiThoughts(thoughts: unknown): string {
if (!Array.isArray(thoughts)) {
return '';
}
return thoughts
.map((item) => {
if (!item || typeof item !== 'object') {
return '';
}
const record = item as AnyRecord;
const subject = typeof record.subject === 'string' ? record.subject.trim() : '';
const description = typeof record.description === 'string' ? record.description.trim() : '';
if (subject && description) {
return `${subject}: ${description}`;
}
return description || subject;
})
.filter(Boolean)
.join('\n');
}
function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
if (!tokens || typeof tokens !== 'object') {
return undefined;
}
const record = tokens as AnyRecord;
const input = Number(record.input || 0);
const output = Number(record.output || 0);
const cached = Number(record.cached || 0);
const thoughts = Number(record.thoughts || 0);
const tool = Number(record.tool || 0);
const totalFromFields = input + output + cached + thoughts + tool;
const total = Number(record.total || totalFromFields || 0);
return {
used: total,
total: total,
breakdown: {
input,
output,
cached,
thoughts,
tool,
},
};
}
async function getGeminiLegacySessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
try {
const data = await fs.readFile(sessionFilePath, 'utf8');
const session = JSON.parse(data) as AnyRecord;
const sourceMessages = Array.isArray(session.messages) ? session.messages : [];
const messages: AnyRecord[] = [];
for (const msg of sourceMessages) {
const role = mapGeminiRole(msg.type ?? msg.role);
if (!role) {
continue;
}
messages.push({
type: 'message',
uuid: typeof msg.id === 'string' ? msg.id : undefined,
message: { role, content: msg.content },
timestamp: msg.timestamp || null,
});
}
return { messages };
} catch {
return { messages: [] };
}
}
async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
const messages: AnyRecord[] = [];
let tokenUsage: AnyRecord | undefined;
try {
const fileStream = fsSync.createReadStream(sessionFilePath);
const lineReader = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
for await (const line of lineReader) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let entry: AnyRecord;
try {
entry = JSON.parse(trimmed) as AnyRecord;
} catch {
continue;
}
// Metadata/update lines (e.g. {$set:{lastUpdated:...}}) do not represent chat messages.
if (entry.$set) {
continue;
}
const role = mapGeminiRole(entry.type);
if (role) {
const textContent = extractGeminiTextContent(entry.content);
if (textContent.trim()) {
messages.push({
type: 'message',
uuid: typeof entry.id === 'string' ? entry.id : undefined,
message: { role, content: textContent },
timestamp: entry.timestamp || null,
});
}
const thinkingContent = extractGeminiThoughts(entry.thoughts);
if (thinkingContent.trim()) {
messages.push({
type: 'thinking',
uuid: typeof entry.id === 'string' ? `${entry.id}_thinking` : undefined,
message: { role: 'assistant', content: thinkingContent },
timestamp: entry.timestamp || null,
isReasoning: true,
});
}
if (role === 'assistant') {
const usage = buildGeminiTokenUsage(entry.tokens);
if (usage) {
tokenUsage = usage;
}
}
continue;
}
if (entry.type === 'tool_use') {
messages.push({
type: 'tool_use',
uuid: typeof entry.id === 'string' ? entry.id : undefined,
timestamp: entry.timestamp || null,
toolName: entry.tool_name || entry.name || 'Tool',
toolInput: entry.parameters ?? entry.input ?? entry.arguments ?? '',
toolCallId: entry.tool_id || entry.toolCallId || entry.id,
});
continue;
}
if (entry.type === 'tool_result') {
messages.push({
type: 'tool_result',
uuid: typeof entry.id === 'string' ? entry.id : undefined,
timestamp: entry.timestamp || null,
toolCallId: entry.tool_id || entry.toolCallId || entry.id || '',
output: entry.output ?? entry.result ?? '',
isError: Boolean(entry.error) || entry.status === 'error',
});
}
}
} catch {
return { messages: [] };
}
messages.sort(
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
);
return { messages, tokenUsage };
}
async function getGeminiCliSessionMessages(sessionId: string): Promise<GeminiHistoryResult> {
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
if (!sessionFilePath) {
return { messages: [] };
}
if (sessionFilePath.endsWith('.jsonl')) {
return getGeminiJsonlSessionMessages(sessionFilePath);
}
return getGeminiLegacySessionMessages(sessionFilePath);
}
export class GeminiSessionsProvider implements IProviderSessions {
/**
* Normalizes live Gemini stream-json events into the shared message shape.
*
* Gemini history uses a different session file shape, so fetchHistory handles
* that separately after loading raw persisted messages.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (!raw) {
return [];
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'message' && raw.role === 'assistant') {
const content = raw.content || '';
const messages: NormalizedMessage[] = [];
if (content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_delta',
content,
}));
}
if (raw.delta !== true) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
}));
}
return messages;
}
if (raw.type === 'tool_use') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool_name,
toolInput: raw.parameters || {},
toolId: raw.tool_id || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.tool_id || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: raw.status === 'error',
})];
}
if (raw.type === 'result') {
const messages = [createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
})];
if (raw.stats?.total_tokens) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'status',
text: 'Complete',
tokens: raw.stats.total_tokens,
canInterrupt: false,
}));
}
return messages;
}
if (raw.type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error || raw.message || 'Unknown Gemini streaming error',
})];
}
return [];
}
/**
* Loads Gemini history from Gemini CLI session files on disk.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
let result: GeminiHistoryResult;
try {
result = await getGeminiCliSessionMessages(sessionId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = result.messages;
const normalized: NormalizedMessage[] = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'thinking' || raw.isReasoning) {
const thinkingContent = typeof raw.message?.content === 'string'
? raw.message.content
: typeof raw.content === 'string'
? raw.content
: '';
if (thinkingContent.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: thinkingContent,
}));
}
continue;
}
if (raw.type === 'tool_use' || raw.toolName) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName || 'Tool',
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
}));
continue;
}
if (raw.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: Boolean(raw.isError),
}));
continue;
}
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) {
continue;
}
const normalizedRole = role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx] as AnyRecord | string;
if (typeof part === 'string' && part.trim()) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part,
}));
continue;
}
if (!part || typeof part !== 'object') {
continue;
}
if ((part.type === 'text' || !part.type) && typeof part.text === 'string' && part.text.trim()) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
}));
} else {
const textContent = extractGeminiTextContent(content);
if (textContent.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: textContent,
}));
}
}
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
const start = Math.max(0, offset);
const pageLimit = limit === null ? null : Math.max(0, limit);
const messages = pageLimit === null
? normalized.slice(start)
: normalized.slice(start, start + pageLimit);
return {
messages,
total: normalized.length,
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
offset: start,
limit: pageLimit,
tokenUsage: result.tokenUsage,
};
}
}

View File

@@ -0,0 +1,17 @@
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js';
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
export class GeminiProvider extends AbstractProvider {
readonly mcp = new GeminiMcpProvider();
readonly auth: IProviderAuth = new GeminiProviderAuth();
readonly sessions: IProviderSessions = new GeminiSessionsProvider();
readonly sessionSynchronizer: IProviderSessionSynchronizer = new GeminiSessionSynchronizer();
constructor() {
super('gemini');
}
}

View File

@@ -0,0 +1,36 @@
import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js';
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
const providers: Record<LLMProvider, IProvider> = {
claude: new ClaudeProvider(),
codex: new CodexProvider(),
cursor: new CursorProvider(),
gemini: new GeminiProvider(),
};
/**
* Central registry for resolving concrete provider implementations by id.
*/
export const providerRegistry = {
listProviders(): IProvider[] {
return Object.values(providers);
},
resolveProvider(provider: string): IProvider {
const key = provider as LLMProvider;
const resolvedProvider = providers[key];
if (!resolvedProvider) {
throw new AppError(`Unsupported provider "${provider}".`, {
code: 'UNSUPPORTED_PROVIDER',
statusCode: 400,
});
}
return resolvedProvider;
},
};

View File

@@ -0,0 +1,425 @@
import express, { type Request, type Response } from 'express';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
const router = express.Router();
const readPathParam = (value: unknown, name: string): string => {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0];
}
throw new AppError(`${name} path parameter is invalid.`, {
code: 'INVALID_PATH_PARAMETER',
statusCode: 400,
});
};
const normalizeProviderParam = (value: unknown): string =>
readPathParam(value, 'provider').trim().toLowerCase();
const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/;
const parseSessionId = (value: unknown): string => {
const sessionId = readPathParam(value, 'sessionId').trim();
if (!SESSION_ID_PATTERN.test(sessionId)) {
throw new AppError('Invalid sessionId.', {
code: 'INVALID_SESSION_ID',
statusCode: 400,
});
}
return sessionId;
};
const readOptionalQueryString = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
};
const parseOptionalBooleanQuery = (value: unknown, name: string): boolean | undefined => {
if (value === undefined) {
return undefined;
}
const normalized = readOptionalQueryString(value);
if (!normalized) {
return undefined;
}
if (normalized === 'true') {
return true;
}
if (normalized === 'false') {
return false;
}
throw new AppError(`${name} must be "true" or "false".`, {
code: 'INVALID_QUERY_PARAMETER',
statusCode: 400,
});
};
const parseMcpScope = (value: unknown): McpScope | undefined => {
if (value === undefined) {
return undefined;
}
const normalized = readOptionalQueryString(value);
if (!normalized) {
return undefined;
}
if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
return normalized;
}
throw new AppError(`Unsupported MCP scope "${normalized}".`, {
code: 'INVALID_MCP_SCOPE',
statusCode: 400,
});
};
const parseMcpTransport = (value: unknown): McpTransport => {
const normalized = readOptionalQueryString(value);
if (!normalized) {
throw new AppError('transport is required.', {
code: 'MCP_TRANSPORT_REQUIRED',
statusCode: 400,
});
}
if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
return normalized;
}
throw new AppError(`Unsupported MCP transport "${normalized}".`, {
code: 'INVALID_MCP_TRANSPORT',
statusCode: 400,
});
};
const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const name = readOptionalQueryString(body.name);
if (!name) {
throw new AppError('name is required.', {
code: 'MCP_NAME_REQUIRED',
statusCode: 400,
});
}
const transport = parseMcpTransport(body.transport);
const scope = parseMcpScope(body.scope);
const workspacePath = readOptionalQueryString(body.workspacePath);
return {
name,
transport,
scope,
workspacePath,
command: readOptionalQueryString(body.command),
args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
env: typeof body.env === 'object' && body.env !== null
? Object.fromEntries(
Object.entries(body.env as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
cwd: readOptionalQueryString(body.cwd),
url: readOptionalQueryString(body.url),
headers: typeof body.headers === 'object' && body.headers !== null
? Object.fromEntries(
Object.entries(body.headers as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
envVars: Array.isArray(body.envVars)
? body.envVars.filter((entry): entry is string => typeof entry === 'string')
: undefined,
bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
? Object.fromEntries(
Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
};
};
const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value);
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
return normalized;
}
throw new AppError(`Unsupported provider "${normalized}".`, {
code: 'UNSUPPORTED_PROVIDER',
statusCode: 400,
});
};
const parseSessionRenameSummary = (payload: unknown): string => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const summary = typeof body.summary === 'string' ? body.summary.trim() : '';
if (!summary) {
throw new AppError('Summary is required.', {
code: 'INVALID_SESSION_SUMMARY',
statusCode: 400,
});
}
if (summary.length > 500) {
throw new AppError('Summary must not exceed 500 characters.', {
code: 'INVALID_SESSION_SUMMARY',
statusCode: 400,
});
}
return summary;
};
const parseSessionSearchQuery = (value: unknown): string => {
const query = readOptionalQueryString(value) ?? '';
if (query.length < 2) {
throw new AppError('Query must be at least 2 characters', {
code: 'INVALID_SEARCH_QUERY',
statusCode: 400,
});
}
return query;
};
const parseSessionSearchLimit = (value: unknown): number => {
const raw = readOptionalQueryString(value);
if (!raw) {
return 50;
}
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) {
throw new AppError('limit must be a valid integer.', {
code: 'INVALID_QUERY_PARAMETER',
statusCode: 400,
});
}
return Math.max(1, Math.min(parsed, 100));
};
router.get(
'/:provider/auth/status',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const status = await providerAuthService.getProviderAuthStatus(provider);
res.json(createApiSuccessResponse(status));
}),
);
// ----------------- MCP routes -----------------
router.get(
'/:provider/mcp/servers',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
const scope = parseMcpScope(req.query.scope);
if (scope) {
const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath });
res.json(createApiSuccessResponse({ provider, scope, servers }));
return;
}
const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath });
res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
}),
);
router.post(
'/:provider/mcp/servers',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const payload = parseMcpUpsertPayload(req.body);
const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
res.status(201).json(createApiSuccessResponse({ server }));
}),
);
router.delete(
'/:provider/mcp/servers/:name',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const scope = parseMcpScope(req.query.scope);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
const result = await providerMcpService.removeProviderMcpServer(provider, {
name: readPathParam(req.params.name, 'name'),
scope,
workspacePath,
});
res.json(createApiSuccessResponse(result));
}),
);
router.post(
'/mcp/servers/global',
asyncHandler(async (req: Request, res: Response) => {
const payload = parseMcpUpsertPayload(req.body);
if (payload.scope === 'local') {
throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
code: 'INVALID_GLOBAL_MCP_SCOPE',
statusCode: 400,
});
}
const results = await providerMcpService.addMcpServerToAllProviders({
...payload,
scope: payload.scope === 'user' ? 'user' : 'project',
});
res.status(201).json(createApiSuccessResponse({ results }));
}),
);
// ----------------- Session routes -----------------
router.delete(
'/sessions/:sessionId',
asyncHandler(async (req: Request, res: Response) => {
const sessionId = parseSessionId(req.params.sessionId);
const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? false;
const result = await sessionsService.deleteSessionById(sessionId, deletedFromDisk);
res.json(createApiSuccessResponse(result));
}),
);
router.put(
'/sessions/:sessionId',
asyncHandler(async (req: Request, res: Response) => {
const sessionId = parseSessionId(req.params.sessionId);
const summary = parseSessionRenameSummary(req.body);
const result = sessionsService.renameSessionById(sessionId, summary);
res.json(createApiSuccessResponse(result));
}),
);
router.get(
'/sessions/:sessionId/messages',
asyncHandler(async (req: Request, res: Response) => {
const sessionId = parseSessionId(req.params.sessionId);
const limitRaw = readOptionalQueryString(req.query.limit);
const offsetRaw = readOptionalQueryString(req.query.offset);
let limit: number | null = null;
if (limitRaw !== undefined) {
const parsedLimit = Number.parseInt(limitRaw, 10);
if (Number.isNaN(parsedLimit) || parsedLimit < 0) {
throw new AppError('limit must be a non-negative integer.', {
code: 'INVALID_QUERY_PARAMETER',
statusCode: 400,
});
}
limit = parsedLimit;
}
let offset = 0;
if (offsetRaw !== undefined) {
const parsedOffset = Number.parseInt(offsetRaw, 10);
if (Number.isNaN(parsedOffset) || parsedOffset < 0) {
throw new AppError('offset must be a non-negative integer.', {
code: 'INVALID_QUERY_PARAMETER',
statusCode: 400,
});
}
offset = parsedOffset;
}
const result = await sessionsService.fetchHistory(sessionId, {
limit,
offset,
});
res.json(result);
}),
);
router.get('/search/sessions', asyncHandler(async (req: Request, res: Response) => {
const query = parseSessionSearchQuery(req.query.q);
const limit = parseSessionSearchLimit(req.query.limit);
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
let closed = false;
const abortController = new AbortController();
req.on('close', () => {
closed = true;
abortController.abort();
});
try {
await sessionConversationsSearchService.search({
query,
limit,
signal: abortController.signal,
onProgress: ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
if (closed) {
return;
}
if (projectResult) {
res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
return;
}
res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
},
});
if (!closed) {
res.write('event: done\ndata: {}\n\n');
}
} catch (error) {
console.error('Error searching conversations:', error);
if (!closed) {
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
}
} finally {
if (!closed) {
res.end();
}
}
}));
export default router;

View File

@@ -0,0 +1,94 @@
import os from 'node:os';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
/** Cursor MCP is not supported on Windows hosts (no Cursor CLI integration). */
function includeProviderInGlobalMcp(providerId: LLMProvider): boolean {
if (providerId === 'cursor' && os.platform() === 'win32') {
return false;
}
return true;
}
export const providerMcpService = {
/**
* Lists MCP servers for one provider grouped by supported scopes.
*/
async listProviderMcpServers(
providerName: string,
options?: { workspacePath?: string },
): Promise<Record<McpScope, ProviderMcpServer[]>> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.listServers(options);
},
/**
* Lists MCP servers for one provider scope.
*/
async listProviderMcpServersForScope(
providerName: string,
scope: McpScope,
options?: { workspacePath?: string },
): Promise<ProviderMcpServer[]> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.listServersForScope(scope, options);
},
/**
* Adds or updates one provider MCP server.
*/
async upsertProviderMcpServer(
providerName: string,
input: UpsertProviderMcpServerInput,
): Promise<ProviderMcpServer> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.upsertServer(input);
},
/**
* Removes one provider MCP server.
*/
async removeProviderMcpServer(
providerName: string,
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.removeServer(input);
},
/**
* Adds one HTTP/stdio MCP server to every provider.
*/
async addMcpServerToAllProviders(
input: Omit<UpsertProviderMcpServerInput, 'scope'> & { scope?: Exclude<McpScope, 'local'> },
): Promise<Array<{ provider: LLMProvider; created: boolean; error?: string }>> {
if (input.transport !== 'stdio' && input.transport !== 'http') {
throw new AppError('Global MCP add supports only "stdio" and "http".', {
code: 'INVALID_GLOBAL_MCP_TRANSPORT',
statusCode: 400,
});
}
const scope = input.scope ?? 'project';
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
const providers = providerRegistry.listProviders().filter((p) => includeProviderInGlobalMcp(p.id));
for (const provider of providers) {
try {
await provider.mcp.upsertServer({ ...input, scope });
results.push({ provider: provider.id, created: true });
} catch (error) {
results.push({
provider: provider.id,
created: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return results;
},
};

View File

@@ -0,0 +1,26 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { LLMProvider, ProviderAuthStatus } from '@/shared/types.js';
export const providerAuthService = {
/**
* Resolves a provider and returns its installation/authentication status.
*/
async getProviderAuthStatus(providerName: string): Promise<ProviderAuthStatus> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.auth.getStatus();
},
/**
* Returns whether a provider runtime appears installed.
* Falls back to true if status lookup itself fails so callers preserve the
* original runtime error instead of replacing it with a status-check failure.
*/
async isProviderInstalled(providerName: LLMProvider): Promise<boolean> {
try {
const status = await this.getProviderAuthStatus(providerName);
return status.installed;
} catch {
return true;
}
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
import { scanStateDb } from '@/modules/database/index.js';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { LLMProvider } from '@/shared/types.js';
type SessionSynchronizeResult = {
processedByProvider: Record<LLMProvider, number>;
failures: string[];
};
/**
* Orchestrates provider-specific session indexers and indexed-session lifecycle operations.
*/
export const sessionSynchronizerService = {
/**
* Runs all provider synchronizers and updates scan_state.last_scanned_at.
*/
async synchronizeSessions(): Promise<SessionSynchronizeResult> {
const lastScanAt = scanStateDb.getLastScannedAt();
const scanBoundary = new Date();
const processedByProvider: Record<LLMProvider, number> = {
claude: 0,
codex: 0,
cursor: 0,
gemini: 0,
};
const failures: string[] = [];
const results = await Promise.allSettled(
providerRegistry.listProviders().map(async (provider) => ({
provider: provider.id,
processed: await provider.sessionSynchronizer.synchronize(lastScanAt ?? undefined),
}))
);
for (const result of results) {
if (result.status === 'fulfilled') {
processedByProvider[result.value.provider] = result.value.processed;
continue;
}
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
failures.push(reason);
}
if (failures.length === 0) {
scanStateDb.updateLastScannedAt(scanBoundary);
} else {
console.warn(
`[Sessions] Skipping scan_state cursor advance because ${failures.length} provider sync(s) failed.`,
);
}
return {
processedByProvider,
failures,
};
},
/**
* Indexes one provider artifact file without running a full provider rescan.
*/
async synchronizeProviderFile(
provider: LLMProvider,
filePath: string
): Promise<{ provider: LLMProvider; indexed: boolean; sessionId: string | null }> {
const resolvedProvider = providerRegistry.resolveProvider(provider);
const sessionId = await resolvedProvider.sessionSynchronizer.synchronizeFile(filePath);
return {
provider,
indexed: Boolean(sessionId),
sessionId,
};
},
};

View File

@@ -0,0 +1,283 @@
import os from 'node:os';
import path from 'node:path';
import { promises as fsPromises } from 'node:fs';
import chokidar, { type FSWatcher } from 'chokidar';
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
import type { LLMProvider } from '@/shared/types.js';
import { getProjectsWithSessions } from '@/modules/projects/index.js';
type WatcherEventType = 'add' | 'change';
const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = [
{
provider: 'claude',
rootPath: path.join(os.homedir(), '.claude', 'projects'),
},
{
provider: 'cursor',
rootPath: path.join(os.homedir(), '.cursor', 'chats'),
},
{
provider: 'codex',
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
},
{
provider: 'gemini',
rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
},
{
provider: 'gemini',
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
},
];
const WATCHER_IGNORED_PATTERNS = [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/build/**',
'**/*.tmp',
'**/*.swp',
'**/.DS_Store',
];
const PROJECTS_UPDATE_DEBOUNCE_MS = 500;
const PROJECTS_UPDATE_MAX_WAIT_MS = 2_000;
const watchers: FSWatcher[] = [];
type PendingWatcherUpdate = {
providers: Set<LLMProvider>;
changeTypes: Set<WatcherEventType>;
updatedSessionIds: Set<string>;
};
let pendingWatcherUpdate: PendingWatcherUpdate | null = null;
let pendingWatcherUpdateStartedAt: number | null = null;
let pendingWatcherFlushTimer: ReturnType<typeof setTimeout> | null = null;
let watcherRefreshInFlight = false;
let watcherRescheduleAfterRefresh = false;
/**
* Filters watcher events to provider-specific session artifact file types.
*/
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
if (provider === 'gemini') {
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
}
return filePath.endsWith('.jsonl');
}
function clearPendingWatcherFlushTimer(): void {
if (pendingWatcherFlushTimer) {
clearTimeout(pendingWatcherFlushTimer);
pendingWatcherFlushTimer = null;
}
}
function schedulePendingWatcherFlush(): void {
if (!pendingWatcherUpdate) {
return;
}
const now = Date.now();
if (pendingWatcherUpdateStartedAt === null) {
pendingWatcherUpdateStartedAt = now;
}
const elapsed = now - pendingWatcherUpdateStartedAt;
const remainingMaxWait = Math.max(0, PROJECTS_UPDATE_MAX_WAIT_MS - elapsed);
const delay = Math.min(PROJECTS_UPDATE_DEBOUNCE_MS, remainingMaxWait);
clearPendingWatcherFlushTimer();
pendingWatcherFlushTimer = setTimeout(() => {
void flushPendingWatcherUpdate();
}, delay);
}
function queuePendingWatcherUpdate(
eventType: WatcherEventType,
provider: LLMProvider,
updatedSessionId: string | null
): void {
if (!pendingWatcherUpdate) {
pendingWatcherUpdate = {
providers: new Set<LLMProvider>(),
changeTypes: new Set<WatcherEventType>(),
updatedSessionIds: new Set<string>(),
};
}
pendingWatcherUpdate.providers.add(provider);
pendingWatcherUpdate.changeTypes.add(eventType);
if (updatedSessionId) {
pendingWatcherUpdate.updatedSessionIds.add(updatedSessionId);
}
schedulePendingWatcherFlush();
}
async function flushPendingWatcherUpdate(): Promise<void> {
clearPendingWatcherFlushTimer();
if (!pendingWatcherUpdate) {
return;
}
if (watcherRefreshInFlight) {
watcherRescheduleAfterRefresh = true;
return;
}
const queuedUpdate = pendingWatcherUpdate;
pendingWatcherUpdate = null;
pendingWatcherUpdateStartedAt = null;
watcherRefreshInFlight = true;
try {
const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true });
const changeTypes = Array.from(queuedUpdate.changeTypes);
const watchProviders = Array.from(queuedUpdate.providers);
const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds);
// Backward-compatible fields stay populated with the first queued values.
const updateMessage = JSON.stringify({
type: 'projects_updated',
projects: updatedProjects,
timestamp: new Date().toISOString(),
changeType: changeTypes[0] ?? 'change',
updatedSessionId: updatedSessionIds[0] ?? undefined,
watchProvider: watchProviders[0] ?? undefined,
changeTypes,
updatedSessionIds,
watchProviders,
batched: true,
});
connectedClients.forEach(client => {
if (client.readyState === WS_OPEN_STATE) {
client.send(updateMessage);
}
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message });
} finally {
watcherRefreshInFlight = false;
if (pendingWatcherUpdate || watcherRescheduleAfterRefresh) {
watcherRescheduleAfterRefresh = false;
schedulePendingWatcherFlush();
}
}
}
/**
* Handles file watcher updates and triggers provider file-level synchronization.
*/
async function onUpdate(
eventType: WatcherEventType,
filePath: string,
provider: LLMProvider
): Promise<void> {
if (!isWatcherTargetFile(provider, filePath)) {
return;
}
try {
const result = await sessionSynchronizerService.synchronizeProviderFile(provider, filePath);
if (!result.indexed) {
return;
}
console.log(`Session synchronization triggered by ${eventType} event for provider "${provider}"`, {
filePath,
sessionId: result.sessionId,
});
queuePendingWatcherUpdate(eventType, provider, result.sessionId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Session watcher sync failed for provider "${provider}"`, {
eventType,
filePath,
error: message,
});
}
}
/**
* Starts provider filesystem watchers and performs initial DB synchronization.
*/
export async function initializeSessionsWatcher(): Promise<void> {
console.log('Setting up session watchers');
const initialSync = await sessionSynchronizerService.synchronizeSessions();
console.log('Initial session synchronization complete', {
processedByProvider: initialSync.processedByProvider,
failures: initialSync.failures,
});
for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
try {
await fsPromises.mkdir(rootPath, { recursive: true });
const watcher = chokidar.watch(rootPath, {
ignored: WATCHER_IGNORED_PATTERNS,
persistent: true,
ignoreInitial: true,
followSymlinks: false,
depth: 6,
usePolling: true,
interval: 6_000,
binaryInterval: 6_000,
});
watcher
.on('add', (filePath: string) => {
void onUpdate('add', filePath, provider);
})
.on('change', (filePath: string) => {
void onUpdate('change', filePath, provider);
})
.on('error', (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`Session watcher error for provider "${provider}"`, { error: message });
});
watchers.push(watcher);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Failed to initialize session watcher for provider "${provider}"`, {
rootPath,
error: message,
});
}
}
}
/**
* Stops all active provider session watchers.
*/
export async function closeSessionsWatcher(): Promise<void> {
clearPendingWatcherFlushTimer();
await Promise.all(
watchers.map(async (watcher) => {
try {
await watcher.close();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Failed to close session watcher', { error: message });
}
})
);
watchers.length = 0;
pendingWatcherUpdate = null;
pendingWatcherUpdateStartedAt = null;
watcherRefreshInFlight = false;
watcherRescheduleAfterRefresh = false;
}

View File

@@ -0,0 +1,130 @@
import fsp from 'node:fs/promises';
import { sessionsDb } from '@/modules/database/index.js';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type {
FetchHistoryOptions,
FetchHistoryResult,
LLMProvider,
NormalizedMessage,
} from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
/**
* Removes one file if it exists.
*/
async function removeFileIfExists(filePath: string): Promise<boolean> {
try {
await fsp.unlink(filePath);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return false;
}
throw error;
}
}
/**
* Application service for provider-backed session message operations.
*
* Callers pass a provider id and this service resolves the concrete provider
* class, keeping normalization/history call sites decoupled from implementation
* file layout.
*/
export const sessionsService = {
/**
* Lists provider ids that can load session history and normalize live messages.
*/
listProviderIds(): LLMProvider[] {
return providerRegistry.listProviders().map((provider) => provider.id);
},
/**
* Normalizes one provider-native event into frontend session message events.
*/
normalizeMessage(
providerName: string,
raw: unknown,
sessionId: string | null,
): NormalizedMessage[] {
return providerRegistry.resolveProvider(providerName).sessions.normalizeMessage(raw, sessionId);
},
/**
* Fetches persisted history by session id.
*
* Provider and provider-specific lookup hints are resolved from the indexed
* session metadata in the database.
*/
fetchHistory(
sessionId: string,
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
): Promise<FetchHistoryResult> {
const session = sessionsDb.getSessionById(sessionId);
if (!session) {
throw new AppError(`Session "${sessionId}" was not found.`, {
code: 'SESSION_NOT_FOUND',
statusCode: 404,
});
}
const provider = session.provider as LLMProvider;
return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
limit: options.limit ?? null,
offset: options.offset ?? 0,
projectPath: session.project_path ?? '',
});
},
/**
* Deletes one persisted session row by id.
*
* When `deletedFromDisk` is true and a session `jsonl_path` exists, the path
* is deleted from disk before the DB row is removed.
*/
async deleteSessionById(
sessionId: string,
deletedFromDisk = false,
): Promise<{ sessionId: string; deletedFromDisk: boolean }> {
const session = sessionsDb.getSessionById(sessionId);
if (!session) {
throw new AppError(`Session "${sessionId}" was not found.`, {
code: 'SESSION_NOT_FOUND',
statusCode: 404,
});
}
let removedFromDisk = false;
if (deletedFromDisk && session.jsonl_path) {
removedFromDisk = await removeFileIfExists(session.jsonl_path);
}
const deleted = sessionsDb.deleteSessionById(sessionId);
if (!deleted) {
throw new AppError(`Session "${sessionId}" was not found.`, {
code: 'SESSION_NOT_FOUND',
statusCode: 404,
});
}
return { sessionId, deletedFromDisk: removedFromDisk };
},
/**
* Renames one session by id without requiring the caller to pass provider.
*/
renameSessionById(sessionId: string, summary: string): { sessionId: string; summary: string } {
const session = sessionsDb.getSessionById(sessionId);
if (!session) {
throw new AppError(`Session "${sessionId}" was not found.`, {
code: 'SESSION_NOT_FOUND',
statusCode: 404,
});
}
sessionsDb.updateSessionCustomName(sessionId, summary);
return { sessionId, summary };
},
};

View File

@@ -0,0 +1,27 @@
import type {
IProvider,
IProviderAuth,
IProviderMcp,
IProviderSessionSynchronizer,
IProviderSessions,
} from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js';
/**
* Shared provider base.
*
* Concrete providers must expose auth/MCP handlers and implement message
* normalization/history loading because those behaviors depend on native
* SDK/CLI formats.
*/
export abstract class AbstractProvider implements IProvider {
readonly id: LLMProvider;
abstract readonly mcp: IProviderMcp;
abstract readonly auth: IProviderAuth;
abstract readonly sessions: IProviderSessions;
abstract readonly sessionSynchronizer: IProviderSessionSynchronizer;
protected constructor(id: LLMProvider) {
this.id = id;
}
}

View File

@@ -0,0 +1,151 @@
import path from 'node:path';
import type { IProviderMcp } from '@/shared/interfaces.js';
import type { LLMProvider, McpScope, McpTransport, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
const resolveWorkspacePath = (workspacePath?: string): string =>
path.resolve(workspacePath ?? process.cwd());
const normalizeServerName = (name: string): string => {
const normalized = name.trim();
if (!normalized) {
throw new AppError('MCP server name is required.', {
code: 'MCP_SERVER_NAME_REQUIRED',
statusCode: 400,
});
}
return normalized;
};
/**
* Shared MCP provider for provider-specific config readers/writers.
*/
export abstract class McpProvider implements IProviderMcp {
protected readonly provider: LLMProvider;
protected readonly supportedScopes: McpScope[];
protected readonly supportedTransports: McpTransport[];
protected constructor(
provider: LLMProvider,
supportedScopes: McpScope[],
supportedTransports: McpTransport[],
) {
this.provider = provider;
this.supportedScopes = supportedScopes;
this.supportedTransports = supportedTransports;
}
async listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>> {
const grouped: Record<McpScope, ProviderMcpServer[]> = {
user: [],
local: [],
project: [],
};
for (const scope of this.supportedScopes) {
grouped[scope] = await this.listServersForScope(scope, options);
}
return grouped;
}
async listServersForScope(
scope: McpScope,
options?: { workspacePath?: string },
): Promise<ProviderMcpServer[]> {
if (!this.supportedScopes.includes(scope)) {
return [];
}
const workspacePath = resolveWorkspacePath(options?.workspacePath);
const scopedServers = await this.readScopedServers(scope, workspacePath);
return Object.entries(scopedServers)
.map(([name, rawConfig]) => this.normalizeServerConfig(scope, name, rawConfig))
.filter((entry): entry is ProviderMcpServer => entry !== null);
}
async upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer> {
const scope = input.scope ?? 'project';
this.assertScopeAndTransport(scope, input.transport);
const workspacePath = resolveWorkspacePath(input.workspacePath);
const normalizedName = normalizeServerName(input.name);
const scopedServers = await this.readScopedServers(scope, workspacePath);
scopedServers[normalizedName] = this.buildServerConfig(input);
await this.writeScopedServers(scope, workspacePath, scopedServers);
return {
provider: this.provider,
name: normalizedName,
scope,
transport: input.transport,
command: input.command,
args: input.args,
env: input.env,
cwd: input.cwd,
url: input.url,
headers: input.headers,
envVars: input.envVars,
bearerTokenEnvVar: input.bearerTokenEnvVar,
envHttpHeaders: input.envHttpHeaders,
};
}
async removeServer(
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
const scope = input.scope ?? 'project';
this.assertScope(scope);
const workspacePath = resolveWorkspacePath(input.workspacePath);
const normalizedName = normalizeServerName(input.name);
const scopedServers = await this.readScopedServers(scope, workspacePath);
const removed = Object.prototype.hasOwnProperty.call(scopedServers, normalizedName);
if (removed) {
delete scopedServers[normalizedName];
await this.writeScopedServers(scope, workspacePath, scopedServers);
}
return { removed, provider: this.provider, name: normalizedName, scope };
}
protected abstract readScopedServers(
scope: McpScope,
workspacePath: string,
): Promise<Record<string, unknown>>;
protected abstract writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void>;
protected abstract buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown>;
protected abstract normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null;
protected assertScope(scope: McpScope): void {
if (!this.supportedScopes.includes(scope)) {
throw new AppError(`Provider "${this.provider}" does not support "${scope}" MCP scope.`, {
code: 'MCP_SCOPE_NOT_SUPPORTED',
statusCode: 400,
});
}
}
protected assertScopeAndTransport(scope: McpScope, transport: McpTransport): void {
this.assertScope(scope);
if (!this.supportedTransports.includes(transport)) {
throw new AppError(`Provider "${this.provider}" does not support "${transport}" MCP transport.`, {
code: 'MCP_TRANSPORT_NOT_SUPPORTED',
statusCode: 400,
});
}
}
}

View File

@@ -0,0 +1,293 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import TOML from '@iarna/toml';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { AppError } from '@/shared/utils.js';
const patchHomeDir = (nextHomeDir: string) => {
const original = os.homedir;
(os as any).homedir = () => nextHomeDir;
return () => {
(os as any).homedir = original;
};
};
const readJson = async (filePath: string): Promise<Record<string, unknown>> => {
const content = await fs.readFile(filePath, 'utf8');
return JSON.parse(content) as Record<string, unknown>;
};
/**
* This test covers Claude MCP support for all scopes (user/local/project) and all transports (stdio/http/sse),
* including add, update/list, and remove operations.
*/
test('providerMcpService handles claude MCP scopes/transports with file-backed persistence', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-claude-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-user-stdio',
scope: 'user',
transport: 'stdio',
command: 'npx',
args: ['-y', 'my-server'],
env: { API_KEY: 'secret' },
});
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-local-http',
scope: 'local',
transport: 'http',
url: 'https://example.com/mcp',
headers: { Authorization: 'Bearer token' },
workspacePath,
});
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-project-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
headers: { 'X-API-Key': 'abc' },
workspacePath,
});
const grouped = await providerMcpService.listProviderMcpServers('claude', { workspacePath });
assert.ok(grouped.user.some((server) => server.name === 'claude-user-stdio' && server.transport === 'stdio'));
assert.ok(grouped.local.some((server) => server.name === 'claude-local-http' && server.transport === 'http'));
assert.ok(grouped.project.some((server) => server.name === 'claude-project-sse' && server.transport === 'sse'));
// update behavior is the same upsert route with same name
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-project-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse-updated',
headers: { 'X-API-Key': 'updated' },
workspacePath,
});
const projectConfig = await readJson(path.join(workspacePath, '.mcp.json'));
const projectServers = projectConfig.mcpServers as Record<string, unknown>;
const projectServer = projectServers['claude-project-sse'] as Record<string, unknown>;
assert.equal(projectServer.url, 'https://example.com/sse-updated');
const removeResult = await providerMcpService.removeProviderMcpServer('claude', {
name: 'claude-local-http',
scope: 'local',
workspacePath,
});
assert.equal(removeResult.removed, true);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers Codex MCP support for user/project scopes, stdio/http formats,
* and validation for unsupported scope/transport combinations.
*/
test('providerMcpService handles codex MCP TOML config and capability validation', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-codex-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-user-stdio',
scope: 'user',
transport: 'stdio',
command: 'python',
args: ['server.py'],
env: { API_KEY: 'x' },
envVars: ['API_KEY'],
cwd: '/tmp',
});
await providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-project-http',
scope: 'project',
transport: 'http',
url: 'https://codex.example.com/mcp',
headers: { 'X-Custom-Header': 'value' },
envHttpHeaders: { 'X-API-Key': 'MY_API_KEY_ENV' },
bearerTokenEnvVar: 'MY_API_TOKEN',
workspacePath,
});
const userTomlPath = path.join(tempRoot, '.codex', 'config.toml');
const userConfig = TOML.parse(await fs.readFile(userTomlPath, 'utf8')) as Record<string, unknown>;
const userServers = userConfig.mcp_servers as Record<string, unknown>;
const userStdio = userServers['codex-user-stdio'] as Record<string, unknown>;
assert.equal(userStdio.command, 'python');
const projectTomlPath = path.join(workspacePath, '.codex', 'config.toml');
const projectConfig = TOML.parse(await fs.readFile(projectTomlPath, 'utf8')) as Record<string, unknown>;
const projectServers = projectConfig.mcp_servers as Record<string, unknown>;
const projectHttp = projectServers['codex-project-http'] as Record<string, unknown>;
assert.equal(projectHttp.url, 'https://codex.example.com/mcp');
await assert.rejects(
providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-local',
scope: 'local',
transport: 'stdio',
command: 'node',
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
error.statusCode === 400,
);
await assert.rejects(
providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
workspacePath,
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
error.statusCode === 400,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
*/
test('providerMcpService handles gemini and cursor MCP JSON config formats', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-gc-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('gemini', {
name: 'gemini-stdio',
scope: 'user',
transport: 'stdio',
command: 'node',
args: ['server.js'],
env: { TOKEN: '$TOKEN' },
cwd: './server',
});
await providerMcpService.upsertProviderMcpServer('gemini', {
name: 'gemini-http',
scope: 'project',
transport: 'http',
url: 'https://gemini.example.com/mcp',
headers: { Authorization: 'Bearer token' },
workspacePath,
});
await providerMcpService.upsertProviderMcpServer('cursor', {
name: 'cursor-stdio',
scope: 'project',
transport: 'stdio',
command: 'npx',
args: ['-y', 'mcp-server'],
env: { API_KEY: 'value' },
workspacePath,
});
await providerMcpService.upsertProviderMcpServer('cursor', {
name: 'cursor-http',
scope: 'user',
transport: 'http',
url: 'http://localhost:3333/mcp',
headers: { API_KEY: 'value' },
});
const geminiUserConfig = await readJson(path.join(tempRoot, '.gemini', 'settings.json'));
const geminiUserServer = (geminiUserConfig.mcpServers as Record<string, unknown>)['gemini-stdio'] as Record<string, unknown>;
assert.equal(geminiUserServer.command, 'node');
assert.equal(geminiUserServer.type, undefined);
const geminiProjectConfig = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
const geminiProjectServer = (geminiProjectConfig.mcpServers as Record<string, unknown>)['gemini-http'] as Record<string, unknown>;
assert.equal(geminiProjectServer.type, 'http');
const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json'));
const cursorHttpServer = (cursorUserConfig.mcpServers as Record<string, unknown>)['cursor-http'] as Record<string, unknown>;
assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp');
assert.equal(cursorHttpServer.type, undefined);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers the global MCP adder requirement: only http/stdio are allowed and
* one payload is written to all providers.
*/
test('providerMcpService global adder writes to all providers and rejects unsupported transports', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-global-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
const globalResult = await providerMcpService.addMcpServerToAllProviders({
name: 'global-http',
scope: 'project',
transport: 'http',
url: 'https://global.example.com/mcp',
workspacePath,
});
const expectCursorGlobal = process.platform !== 'win32';
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
assert.ok((claudeProject.mcpServers as Record<string, unknown>)['global-http']);
const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record<string, unknown>;
assert.ok((codexProject.mcp_servers as Record<string, unknown>)['global-http']);
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
if (expectCursorGlobal) {
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
}
await assert.rejects(
providerMcpService.addMcpServerToAllProviders({
name: 'global-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
workspacePath,
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_GLOBAL_MCP_TRANSPORT' &&
error.statusCode === 400,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,267 @@
# WebSocket Module
This module owns the server-side WebSocket gateway used by:
1. Chat streaming (`/ws`)
2. Interactive terminal sessions (`/shell`)
3. Plugin WebSocket passthrough (`/plugin-ws/:pluginName`)
It is intentionally structured as **small services** plus a **barrel export** in `index.ts`.
## Public API
`server/modules/websocket/index.ts` exports:
1. `createWebSocketServer(server, dependencies)`
Creates and wires the shared `ws` server.
2. `connectedClients` and `WS_OPEN_STATE`
Shared chat client registry and open-state constant used by other modules.
## Why Dependency Injection Is Used
The module receives runtime-specific functions from `server/index.js` instead of importing legacy runtime files directly.
Benefits:
1. Keeps module boundaries clean (`server/modules/*` architecture rule).
2. Makes each service easier to test in isolation.
3. Keeps WebSocket transport concerns separate from provider runtime concerns.
## Service Map
| File | Responsibility |
|---|---|
| `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname |
| `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` |
| `services/chat-websocket.service.ts` | Handles `/ws` chat protocol and provider command/session control messages |
| `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection |
| `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket |
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) |
| `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant |
## High-Level Architecture
```mermaid
flowchart LR
A[HTTP Server] --> B[createWebSocketServer]
B --> C[verifyWebSocketClient]
B --> D{Pathname}
D -->|/ws| E[handleChatConnection]
D -->|/shell| F[handleShellConnection]
D -->|/plugin-ws/:name| G[handlePluginWsProxy]
D -->|other| H[close()]
E --> I[connectedClients Set]
E --> J[WebSocketWriter]
F --> K[ptySessionsMap]
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
I --> M[projects.service broadcastProgress]
I --> N[sessions-watcher.service projects_updated]
```
## Connection Handshake + Routing
```mermaid
sequenceDiagram
participant Client
participant WSS as WebSocketServer
participant Auth as verifyWebSocketClient
participant Router as connection router
participant Chat as /ws handler
participant Shell as /shell handler
participant Proxy as /plugin-ws handler
Client->>WSS: Upgrade Request
WSS->>Auth: verifyClient(info)
alt Platform mode
Auth->>Auth: authenticateWebSocket(null)
Auth->>Auth: attach request.user
else OSS mode
Auth->>Auth: read token from ?token or Authorization
Auth->>Auth: authenticateWebSocket(token)
Auth->>Auth: attach request.user
end
alt Auth failed
Auth-->>WSS: false (reject handshake)
else Auth ok
Auth-->>WSS: true
WSS->>Router: on("connection", ws, request)
alt pathname == /ws
Router->>Chat: handleChatConnection(ws, request, deps.chat)
else pathname == /shell
Router->>Shell: handleShellConnection(ws, deps.shell)
else pathname startsWith /plugin-ws/
Router->>Proxy: handlePluginWsProxy(ws, pathname, getPluginPort)
else unknown
Router->>Router: ws.close()
end
end
```
## `/ws` Chat Flow
When a chat socket connects:
1. Add socket to `connectedClients`.
2. Build `WebSocketWriter` (captures `userId` from authenticated request).
3. Parse each incoming message with `parseIncomingJsonObject`.
4. Dispatch by `data.type`.
5. On close, remove socket from `connectedClients`.
### Chat Message Dispatch
```mermaid
flowchart TD
A[Incoming WS message] --> B[parseIncomingJsonObject]
B -->|invalid| C[send {type:error}]
B -->|ok| D{data.type}
D -->|claude-command| E[queryClaudeSDK]
D -->|cursor-command| F[spawnCursor]
D -->|codex-command| G[queryCodex]
D -->|gemini-command| H[spawnGemini]
D -->|cursor-resume| I[spawnCursor resume]
D -->|abort-session| J[abort by provider]
D -->|claude-permission-response| K[resolveToolApproval]
D -->|cursor-abort| L[abortCursorSession]
D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter]
D -->|get-pending-permissions| N[getPendingApprovalsForSession]
D -->|get-active-sessions| O[getActive*Sessions]
```
### Chat Notes
1. `abort-session` returns a normalized `complete` message with `aborted: true`.
2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
## `/shell` Terminal Flow
The shell handler manages persistent PTY sessions keyed by:
`<projectPath>_<sessionIdOrDefault>[_cmd_<hash>]`
This enables reconnect behavior and isolates command-specific plain-shell sessions.
### Shell Lifecycle
```mermaid
stateDiagram-v2
[*] --> WaitingInit
WaitingInit --> ValidateInit: message.type == init
ValidateInit --> ReconnectExisting: session key exists and not login reset
ValidateInit --> SpawnNewPTY: valid path + valid sessionId
ValidateInit --> EmitError: invalid payload/path/sessionId
ReconnectExisting --> Running: attach ws, replay buffer
SpawnNewPTY --> Running: pty.spawn + wire onData/onExit
Running --> Running: input -> pty.write
Running --> Running: resize -> pty.resize
Running --> Running: onData -> buffer + output + auth_url detection
Running --> Exited: onExit
Running --> Detached: ws close
Detached --> Running: reconnect before timeout
Detached --> Killed: timeout reached -> pty.kill
Exited --> [*]
Killed --> [*]
EmitError --> WaitingInit
```
### Shell Behaviors in Detail
1. `init`:
Reads `projectPath`, `sessionId`, `provider`, `hasSession`, `initialCommand`, `isPlainShell`.
2. Login reset:
For login-like commands, existing keyed PTY session is killed and recreated.
3. Validation:
Path must exist and be a directory; `sessionId` must match safe pattern.
4. Command build:
Provider-specific command construction with resume semantics.
5. PTY output buffering:
Stores up to 5000 chunks for replay on reconnect.
6. URL detection:
Strips ANSI, accumulates text buffer, extracts URLs, emits `auth_url` once per normalized URL, supports `autoOpen`.
7. Close behavior:
Socket disconnect does not instantly kill PTY; session is kept alive and terminated on timeout.
## `/plugin-ws/:pluginName` Proxy Flow
```mermaid
sequenceDiagram
participant Client
participant Proxy as handlePluginWsProxy
participant PM as getPluginPort
participant Upstream as Plugin WS
Client->>Proxy: Connect /plugin-ws/:name
Proxy->>Proxy: Validate pluginName regex
alt Invalid name
Proxy-->>Client: close(4400, "Invalid plugin name")
else Valid
Proxy->>PM: getPluginPort(name)
alt Plugin not running
Proxy-->>Client: close(4404, "Plugin not running")
else Port found
Proxy->>Upstream: new WebSocket(ws://127.0.0.1:port/ws)
Client-->>Upstream: relay messages bidirectionally
Upstream-->>Client: relay messages bidirectionally
Upstream-->>Client: close propagation
Client-->>Upstream: close propagation
Upstream-->>Client: close(4502, "Upstream error") on upstream error
end
end
```
## Shared Client Registry and Broadcasts
Only chat sockets (`/ws`) are tracked in `connectedClients`.
That shared set is consumed by:
1. `modules/projects/services/projects-with-sessions-fetch.service.ts`
Broadcasts `loading_progress` while project snapshots are being built.
2. `modules/providers/services/sessions-watcher.service.ts`
Broadcasts `projects_updated` when provider session artifacts change.
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
## Writer Adapter (`WebSocketWriter`)
`WebSocketWriter` normalizes chat transport behavior to match existing writer-style interfaces used elsewhere.
Methods:
1. `send(data)`
JSON-serializes and sends only if socket is open.
2. `setSessionId(sessionId)` / `getSessionId()`
Supports provider session bookkeeping and resume flows.
3. `updateWebSocket(newRawWs)`
Allows active session stream redirection on reconnect.
## Error Handling and Close Codes
Current explicit close codes in this module:
1. `4400`: Invalid plugin name
2. `4404`: Plugin not running
3. `4502`: Upstream plugin WebSocket error
Other errors:
1. Chat handler catches and emits `{ type: "error", error }`.
2. Shell handler catches and writes terminal-visible error output.
3. Unknown websocket paths are closed immediately.
## Extending This Module
To add a new websocket route:
1. Add a new handler service under `services/`.
2. Extend `WebSocketServerDependencies` in `websocket-server.service.ts` if needed.
3. Add a new pathname branch in the router.
4. Wire dependency injection from `server/index.js`.
5. Keep `index.ts` as barrel-only export surface.

View File

@@ -0,0 +1,2 @@
export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js';
export { createWebSocketServer } from './services/websocket-server.service.js';

View File

@@ -0,0 +1,271 @@
import type { WebSocket } from 'ws';
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
import { WebSocketWriter } from '@/modules/websocket/services/websocket-writer.service.js';
import type {
AnyRecord,
AuthenticatedWebSocketRequest,
LLMProvider,
} from '@/shared/types.js';
import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
type ChatIncomingMessage = AnyRecord & {
type?: string;
command?: string;
options?: AnyRecord;
provider?: string;
sessionId?: string;
requestId?: string;
allow?: unknown;
updatedInput?: unknown;
message?: unknown;
rememberEntry?: unknown;
};
const DEFAULT_PROVIDER: LLMProvider = 'claude';
type ChatWebSocketDependencies = {
queryClaudeSDK: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
abortCursorSession: (sessionId: string) => boolean;
abortCodexSession: (sessionId: string) => boolean;
abortGeminiSession: (sessionId: string) => boolean;
resolveToolApproval: (
requestId: string,
payload: {
allow: boolean;
updatedInput?: unknown;
message?: string;
rememberEntry?: unknown;
}
) => void;
isClaudeSDKSessionActive: (sessionId: string) => boolean;
isCursorSessionActive: (sessionId: string) => boolean;
isCodexSessionActive: (sessionId: string) => boolean;
isGeminiSessionActive: (sessionId: string) => boolean;
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
getPendingApprovalsForSession: (sessionId: string) => unknown[];
getActiveClaudeSDKSessions: () => unknown;
getActiveCursorSessions: () => unknown;
getActiveCodexSessions: () => unknown;
getActiveGeminiSessions: () => unknown;
};
/**
* Normalizes potentially invalid provider names coming from websocket payloads.
*/
function readProvider(value: unknown): LLMProvider {
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini') {
return value;
}
return DEFAULT_PROVIDER;
}
/**
* Extracts the authenticated request user id in the formats currently produced
* by platform and OSS auth code paths.
*/
function readRequestUserId(
request: AuthenticatedWebSocketRequest | undefined
): string | number | null {
const user = request?.user;
if (!user) {
return null;
}
if (typeof user.id === 'string' || typeof user.id === 'number') {
return user.id;
}
if (typeof user.userId === 'string' || typeof user.userId === 'number') {
return user.userId;
}
return null;
}
/**
* Handles authenticated chat websocket messages used by the main chat panel.
*/
export function handleChatConnection(
ws: WebSocket,
request: AuthenticatedWebSocketRequest,
dependencies: ChatWebSocketDependencies
): void {
console.log('[INFO] Chat WebSocket connected');
connectedClients.add(ws);
const writer = new WebSocketWriter(ws, readRequestUserId(request));
ws.on('message', async (rawMessage) => {
try {
const parsed = parseIncomingJsonObject(rawMessage);
if (!parsed) {
throw new Error('Invalid websocket payload');
}
const data = parsed as ChatIncomingMessage;
const messageType = data.type;
if (!messageType) {
throw new Error('Message type is required');
}
if (messageType === 'claude-command') {
await dependencies.queryClaudeSDK(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'cursor-command') {
await dependencies.spawnCursor(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'codex-command') {
await dependencies.queryCodex(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'gemini-command') {
await dependencies.spawnGemini(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'cursor-resume') {
await dependencies.spawnCursor(
'',
{
sessionId: data.sessionId,
resume: true,
cwd: data.options?.cwd,
},
writer
);
return;
}
if (messageType === 'abort-session') {
const provider = readProvider(data.provider);
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
let success = false;
if (provider === 'cursor') {
success = dependencies.abortCursorSession(sessionId);
} else if (provider === 'codex') {
success = dependencies.abortCodexSession(sessionId);
} else if (provider === 'gemini') {
success = dependencies.abortGeminiSession(sessionId);
} else {
success = await dependencies.abortClaudeSDKSession(sessionId);
}
writer.send(
createNormalizedMessage({
kind: 'complete',
exitCode: success ? 0 : 1,
aborted: true,
success,
sessionId,
provider,
})
);
return;
}
if (messageType === 'claude-permission-response') {
if (typeof data.requestId === 'string' && data.requestId.length > 0) {
dependencies.resolveToolApproval(data.requestId, {
allow: Boolean(data.allow),
updatedInput: data.updatedInput,
message: typeof data.message === 'string' ? data.message : undefined,
rememberEntry: data.rememberEntry,
});
}
return;
}
if (messageType === 'cursor-abort') {
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
const success = dependencies.abortCursorSession(sessionId);
writer.send(
createNormalizedMessage({
kind: 'complete',
exitCode: success ? 0 : 1,
aborted: true,
success,
sessionId,
provider: 'cursor',
})
);
return;
}
if (messageType === 'check-session-status') {
const provider = readProvider(data.provider);
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
let isActive = false;
if (provider === 'cursor') {
isActive = dependencies.isCursorSessionActive(sessionId);
} else if (provider === 'codex') {
isActive = dependencies.isCodexSessionActive(sessionId);
} else if (provider === 'gemini') {
isActive = dependencies.isGeminiSessionActive(sessionId);
} else {
isActive = dependencies.isClaudeSDKSessionActive(sessionId);
if (isActive) {
dependencies.reconnectSessionWriter(sessionId, ws);
}
}
writer.send({
type: 'session-status',
sessionId,
provider,
isProcessing: isActive,
});
return;
}
if (messageType === 'get-pending-permissions') {
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
if (sessionId && dependencies.isClaudeSDKSessionActive(sessionId)) {
const pending = dependencies.getPendingApprovalsForSession(sessionId);
writer.send({
type: 'pending-permissions-response',
sessionId,
data: pending,
});
}
return;
}
if (messageType === 'get-active-sessions') {
writer.send({
type: 'active-sessions',
sessions: {
claude: dependencies.getActiveClaudeSDKSessions(),
cursor: dependencies.getActiveCursorSessions(),
codex: dependencies.getActiveCodexSessions(),
gemini: dependencies.getActiveGeminiSessions(),
},
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[ERROR] Chat WebSocket error:', message);
writer.send({
type: 'error',
error: message,
});
}
});
ws.on('close', () => {
console.log('[INFO] Chat client disconnected');
connectedClients.delete(ws);
});
}

View File

@@ -0,0 +1,65 @@
import { WebSocket } from 'ws';
/**
* Proxies an authenticated client websocket to a plugin websocket endpoint.
*/
export function handlePluginWsProxy(
clientWs: WebSocket,
pathname: string,
getPluginPort: (pluginName: string) => number | null
): void {
const pluginName = pathname.replace('/plugin-ws/', '');
if (!pluginName || /[^a-zA-Z0-9_-]/.test(pluginName)) {
clientWs.close(4400, 'Invalid plugin name');
return;
}
const port = getPluginPort(pluginName);
if (!port) {
clientWs.close(4404, 'Plugin not running');
return;
}
const upstream = new WebSocket(`ws://127.0.0.1:${port}/ws`);
upstream.on('open', () => {
console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
});
upstream.on('message', (data) => {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.send(data);
}
});
clientWs.on('message', (data) => {
if (upstream.readyState === WebSocket.OPEN) {
upstream.send(data);
}
});
upstream.on('close', () => {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.close();
}
});
clientWs.on('close', () => {
if (upstream.readyState === WebSocket.OPEN) {
upstream.close();
}
});
upstream.on('error', (error) => {
console.error(`[Plugins] WS proxy error for "${pluginName}":`, error.message);
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.close(4502, 'Upstream error');
}
});
clientWs.on('error', () => {
if (upstream.readyState === WebSocket.OPEN) {
upstream.close();
}
});
}

View File

@@ -0,0 +1,453 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import pty, { type IPty } from 'node-pty';
import { WebSocket, type RawData } from 'ws';
import { parseIncomingJsonObject } from '@/shared/utils.js';
type ShellIncomingMessage = {
type?: string;
data?: string;
cols?: number;
rows?: number;
projectPath?: string;
sessionId?: string;
hasSession?: boolean;
provider?: string;
initialCommand?: string;
isPlainShell?: boolean;
};
type PtySessionEntry = {
pty: IPty;
ws: WebSocket | null;
buffer: string[];
timeoutId: NodeJS.Timeout | null;
projectPath: string;
sessionId: string | null;
};
const ptySessionsMap = new Map<string, PtySessionEntry>();
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
type ShellWebSocketDependencies = {
getSessionById: (sessionId: string) => { cliSessionId?: string } | null | undefined;
stripAnsiSequences: (content: string) => string;
normalizeDetectedUrl: (url: string) => string | null;
extractUrlsFromText: (content: string) => string[];
shouldAutoOpenUrlFromOutput: (content: string) => boolean;
};
/**
* Reads a string field from untyped payloads and falls back when absent.
*/
function readString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
/**
* Reads a boolean field from untyped payloads and falls back when absent.
*/
function readBoolean(value: unknown, fallback = false): boolean {
return typeof value === 'boolean' ? value : fallback;
}
/**
* Reads a finite number field from untyped payloads and falls back when absent.
*/
function readNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
/**
* Parses incoming websocket shell messages and keeps processing safe when
* malformed payloads are received.
*/
function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
const payload = parseIncomingJsonObject(rawMessage);
if (!payload) {
return null;
}
return payload as ShellIncomingMessage;
}
/**
* Resolves provider command line for plain shell and agent-backed shell modes.
*/
function buildShellCommand(
message: ShellIncomingMessage,
dependencies: ShellWebSocketDependencies
): string {
const hasSession = readBoolean(message.hasSession);
const sessionId = readString(message.sessionId);
const initialCommand = readString(message.initialCommand);
const provider = readString(message.provider, 'claude');
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
const isPlainShell =
readBoolean(message.isPlainShell) ||
(!!initialCommand && !hasSession) ||
provider === 'plain-shell';
if (isPlainShell) {
return initialCommand;
}
if (provider === 'cursor') {
if (hasSession && sessionId) {
return `cursor-agent --resume="${sessionId}"`;
}
return 'cursor-agent';
}
if (provider === 'codex') {
if (hasSession && sessionId) {
if (os.platform() === 'win32') {
return `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
}
return `codex resume "${sessionId}" || codex`;
}
return 'codex';
}
if (provider === 'gemini') {
const command = initialCommand || 'gemini';
let resumeId = sessionId;
if (hasSession && sessionId) {
try {
const existingSession = dependencies.getSessionById(sessionId);
if (existingSession && existingSession.cliSessionId) {
resumeId = existingSession.cliSessionId;
if (!safeSessionIdPattern.test(resumeId)) {
resumeId = '';
}
}
} catch (error) {
console.error('Failed to get Gemini CLI session ID:', error);
}
}
if (hasSession && resumeId) {
return `${command} --resume "${resumeId}"`;
}
return command;
}
const command = initialCommand || 'claude';
if (hasSession && sessionId) {
if (os.platform() === 'win32') {
return `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
}
return `claude --resume "${sessionId}" || claude`;
}
return command;
}
/**
* Handles websocket connections used by the standalone shell terminal UI.
*/
export function handleShellConnection(
ws: WebSocket,
dependencies: ShellWebSocketDependencies
): void {
console.log('[INFO] Shell websocket connected');
let shellProcess: IPty | null = null;
let ptySessionKey: string | null = null;
let urlDetectionBuffer = '';
const announcedAuthUrls = new Set<string>();
ws.on('message', async (rawMessage) => {
try {
const data = parseShellMessage(rawMessage);
if (!data?.type) {
throw new Error('Invalid websocket payload');
}
if (data.type === 'init') {
const projectPath = readString(data.projectPath, process.cwd());
const sessionId = readString(data.sessionId) || null;
const hasSession = readBoolean(data.hasSession);
const provider = readString(data.provider, 'claude');
const initialCommand = readString(data.initialCommand);
const isPlainShell =
readBoolean(data.isPlainShell) ||
(!!initialCommand && !hasSession) ||
provider === 'plain-shell';
urlDetectionBuffer = '';
announcedAuthUrls.clear();
const isLoginCommand =
!!initialCommand &&
(initialCommand.includes('setup-token') ||
initialCommand.includes('cursor-agent login') ||
initialCommand.includes('auth login'));
const commandSuffix =
isPlainShell && initialCommand
? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
: '';
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
if (isLoginCommand) {
const oldSession = ptySessionsMap.get(ptySessionKey);
if (oldSession) {
if (oldSession.timeoutId) {
clearTimeout(oldSession.timeoutId);
}
oldSession.pty.kill();
ptySessionsMap.delete(ptySessionKey);
}
}
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
if (existingSession) {
shellProcess = existingSession.pty;
if (existingSession.timeoutId) {
clearTimeout(existingSession.timeoutId);
}
ws.send(
JSON.stringify({
type: 'output',
data: '\x1b[36m[Reconnected to existing session]\x1b[0m\r\n',
})
);
if (existingSession.buffer.length > 0) {
existingSession.buffer.forEach((bufferedData) => {
ws.send(
JSON.stringify({
type: 'output',
data: bufferedData,
})
);
});
}
existingSession.ws = ws;
return;
}
const resolvedProjectPath = path.resolve(projectPath);
try {
const stats = fs.statSync(resolvedProjectPath);
if (!stats.isDirectory()) {
throw new Error('Not a directory');
}
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
return;
}
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
return;
}
const shellCommand = buildShellCommand(data, dependencies);
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const shellArgs =
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
const termCols = readNumber(data.cols, 80);
const termRows = readNumber(data.rows, 24);
shellProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
cols: termCols,
rows: termRows,
cwd: resolvedProjectPath,
env: {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',
},
});
ptySessionsMap.set(ptySessionKey, {
pty: shellProcess,
ws,
buffer: [],
timeoutId: null,
projectPath,
sessionId,
});
shellProcess.onData((chunk) => {
if (!ptySessionKey) {
return;
}
const session = ptySessionsMap.get(ptySessionKey);
if (!session) {
return;
}
if (session.buffer.length < 5000) {
session.buffer.push(chunk);
} else {
session.buffer.shift();
session.buffer.push(chunk);
}
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
let outputData = chunk;
const cleanChunk = dependencies.stripAnsiSequences(chunk);
urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
outputData = outputData.replace(
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
'[INFO] Opening in browser: $1'
);
const emitAuthUrl = (detectedUrl: string, autoOpen = false) => {
const normalizedUrl = dependencies.normalizeDetectedUrl(detectedUrl);
if (!normalizedUrl) {
return;
}
const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
if (isNewUrl) {
announcedAuthUrls.add(normalizedUrl);
session.ws?.send(
JSON.stringify({
type: 'auth_url',
url: normalizedUrl,
autoOpen,
})
);
}
};
const normalizedDetectedUrls = dependencies.extractUrlsFromText(urlDetectionBuffer)
.map((url) => dependencies.normalizeDetectedUrl(url))
.filter((url): url is string => Boolean(url));
const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter(
(url, _, urls) =>
!urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
);
dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
if (
dependencies.shouldAutoOpenUrlFromOutput(cleanChunk) &&
dedupedDetectedUrls.length > 0
) {
const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
current.length > longest.length ? current : longest
);
emitAuthUrl(bestUrl, true);
}
session.ws.send(
JSON.stringify({
type: 'output',
data: outputData,
})
);
}
});
shellProcess.onExit((exitCode) => {
if (!ptySessionKey) {
return;
}
const session = ptySessionsMap.get(ptySessionKey);
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.send(
JSON.stringify({
type: 'output',
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${
exitCode.signal != null ? ` (${exitCode.signal})` : ''
}\x1b[0m\r\n`,
})
);
}
if (session?.timeoutId) {
clearTimeout(session.timeoutId);
}
ptySessionsMap.delete(ptySessionKey);
shellProcess = null;
});
let welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
if (!isPlainShell) {
const providerName =
provider === 'cursor'
? 'Cursor'
: provider === 'codex'
? 'Codex'
: provider === 'gemini'
? 'Gemini'
: 'Claude';
welcomeMsg = hasSession
? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
: `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
}
ws.send(
JSON.stringify({
type: 'output',
data: welcomeMsg,
})
);
return;
}
if (data.type === 'input') {
if (shellProcess) {
shellProcess.write(readString(data.data));
}
return;
}
if (data.type === 'resize') {
if (shellProcess) {
shellProcess.resize(readNumber(data.cols, 80), readNumber(data.rows, 24));
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[ERROR] Shell WebSocket error:', message);
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: 'output',
data: `\r\n\x1b[31mError: ${message}\x1b[0m\r\n`,
})
);
}
}
});
ws.on('close', () => {
if (!ptySessionKey) {
return;
}
const session = ptySessionsMap.get(ptySessionKey);
if (!session) {
return;
}
session.ws = null;
session.timeoutId = setTimeout(() => {
session.pty.kill();
ptySessionsMap.delete(ptySessionKey as string);
}, PTY_SESSION_TIMEOUT);
});
ws.on('error', (error) => {
console.error('[ERROR] Shell WebSocket error:', error);
});
}

View File

@@ -0,0 +1,54 @@
import type { VerifyClientCallbackSync } from 'ws';
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
type WebSocketAuthDependencies = {
isPlatform: boolean;
authenticateWebSocket: (token: string | null) => {
id?: string | number;
userId?: string | number;
username?: string;
[key: string]: unknown;
} | null;
};
/**
* Authenticates websocket upgrade requests before the `connection` handler runs.
*/
export function verifyWebSocketClient(
info: Parameters<VerifyClientCallbackSync<AuthenticatedWebSocketRequest>>[0],
dependencies: WebSocketAuthDependencies
): boolean {
const request = info.req as AuthenticatedWebSocketRequest;
console.log('WebSocket connection attempt to:', request.url);
// Platform mode: use the first DB user and skip token checks.
if (dependencies.isPlatform) {
const user = dependencies.authenticateWebSocket(null);
if (!user) {
console.log('[WARN] Platform mode: No user found in database');
return false;
}
request.user = user;
console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
return true;
}
// OSS mode: read JWT from query string first, then Authorization header.
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
const token =
upgradeUrl.searchParams.get('token') ??
request.headers.authorization?.split(' ')[1] ??
null;
const user = dependencies.authenticateWebSocket(token);
if (!user) {
console.log('[WARN] WebSocket authentication failed');
return false;
}
request.user = user;
console.log('[OK] WebSocket authenticated for user:', user.username);
return true;
}

View File

@@ -0,0 +1,58 @@
import type { Server as HttpServer } from 'node:http';
import { WebSocketServer, type VerifyClientCallbackSync } from 'ws';
import { handleChatConnection } from '@/modules/websocket/services/chat-websocket.service.js';
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
type WebSocketServerDependencies = {
verifyClient: Parameters<typeof verifyWebSocketClient>[1];
chat: Parameters<typeof handleChatConnection>[2];
shell: Parameters<typeof handleShellConnection>[1];
getPluginPort: Parameters<typeof handlePluginWsProxy>[2];
};
/**
* Creates and wires the server-wide websocket gateway used for chat, shell, and
* plugin proxy routes.
*/
export function createWebSocketServer(
server: HttpServer,
dependencies: WebSocketServerDependencies
): WebSocketServer {
const wss = new WebSocketServer({
server,
verifyClient: ((
info: Parameters<VerifyClientCallbackSync<AuthenticatedWebSocketRequest>>[0]
) => verifyWebSocketClient(info, dependencies.verifyClient)),
});
wss.on('connection', (ws, request) => {
const incomingRequest = request as AuthenticatedWebSocketRequest;
const url = incomingRequest.url ?? '/';
const pathname = new URL(url, 'http://localhost').pathname;
if (pathname === '/shell') {
handleShellConnection(ws, dependencies.shell);
return;
}
if (pathname === '/ws') {
handleChatConnection(ws, incomingRequest, dependencies.chat);
return;
}
if (pathname.startsWith('/plugin-ws/')) {
handlePluginWsProxy(ws, pathname, dependencies.getPluginPort);
return;
}
console.log('[WARN] Unknown WebSocket path:', pathname);
ws.close();
});
return wss;
}

View File

@@ -0,0 +1,16 @@
import type { RealtimeClientConnection } from '@/shared/types.js';
/**
* Numeric readyState for an open WebSocket connection.
*
* We keep this in module state so services that broadcast updates do not need
* to import `ws` directly just to compare open/closed state.
*/
export const WS_OPEN_STATE = 1;
/**
* Shared registry of active chat WebSocket connections.
*
* Project/session services publish realtime updates by iterating this set.
*/
export const connectedClients = new Set<RealtimeClientConnection>();

View File

@@ -0,0 +1,38 @@
import { WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
import type { RealtimeClientConnection } from '@/shared/types.js';
/**
* Thin transport adapter that gives WebSocket connections the same interface as
* SSE writers used by API routes (`send`, `setSessionId`, `getSessionId`).
*/
export class WebSocketWriter {
ws: RealtimeClientConnection;
sessionId: string | null;
userId: string | number | null;
isWebSocketWriter: boolean;
constructor(ws: RealtimeClientConnection, userId: string | number | null = null) {
this.ws = ws;
this.sessionId = null;
this.userId = userId;
this.isWebSocketWriter = true;
}
send(data: unknown): void {
if (this.ws.readyState === WS_OPEN_STATE) {
this.ws.send(JSON.stringify(data));
}
}
updateWebSocket(newRawWs: RealtimeClientConnection): void {
this.ws = newRawWs;
}
setSessionId(sessionId: string): void {
this.sessionId = sessionId;
}
getSessionId(): string | null {
return this.sessionId;
}
}

View File

@@ -15,9 +15,9 @@
import { Codex } from '@openai/codex-sdk';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { codexAdapter } from './providers/codex/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createNormalizedMessage } from './shared/utils.js';
// Track active sessions
const activeCodexSessions = new Map();
@@ -265,7 +265,7 @@ export async function queryCodex(command, options = {}, ws) {
const transformed = transformCodexEvent(event);
// Normalize the transformed event into NormalizedMessage(s) via adapter
const normalizedMsgs = codexAdapter.normalizeMessage(transformed, currentSessionId);
const normalizedMsgs = sessionsService.normalizeMessage('codex', transformed, currentSessionId);
for (const msg of normalizedMsgs) {
sendMessage(ws, msg);
}
@@ -311,7 +311,7 @@ export async function queryCodex(command, options = {}, ws) {
console.error('[Codex] Error:', error);
// Check if Codex SDK is available for a clearer error message
const installed = getStatusChecker('codex')?.checkInstalled() ?? true;
const installed = await providerAuthService.isProviderInstalled('codex');
const errorContent = !installed
? 'Codex CLI is not configured. Please set up authentication first.'
: error.message;

File diff suppressed because it is too large Load Diff

View File

@@ -1,278 +0,0 @@
/**
* Claude provider adapter.
*
* Normalizes Claude SDK session history into NormalizedMessage format.
* @module adapters/claude
*/
import { getSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js';
import { isInternalContent } from '../utils.js';
const PROVIDER = 'claude';
/**
* Normalize a raw JSONL message or realtime SDK event into NormalizedMessage(s).
* Handles both history entries (JSONL `{ message: { role, content } }`) and
* realtime streaming events (`content_block_delta`, `content_block_stop`, etc.).
* @param {object} raw - A single entry from JSONL or a live SDK event
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
// ── Streaming events (realtime) ──────────────────────────────────────────
if (raw.type === 'content_block_delta' && raw.delta?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
}
if (raw.type === 'content_block_stop') {
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
}
// ── History / full-message events ────────────────────────────────────────
const messages = [];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('claude');
// User message
if (raw.message?.role === 'user' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
// Handle tool_result parts
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: `${baseId}_tr_${part.tool_use_id}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id,
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
}));
} else if (part.type === 'text') {
// Regular text parts from user
const text = part.text || '';
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
}
// If no text parts were found, check if it's a pure user message
if (messages.length === 0) {
const textParts = raw.message.content
.filter(p => p.type === 'text')
.map(p => p.text)
.filter(Boolean)
.join('\n');
if (textParts && !isInternalContent(textParts)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: textParts,
}));
}
}
} else if (typeof raw.message.content === 'string') {
const text = raw.message.content;
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
return messages;
}
// Thinking message
if (raw.type === 'thinking' && raw.message?.content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message.content,
}));
return messages;
}
// Tool use result (codex-style in Claude)
if (raw.type === 'tool_use' && raw.toolName) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName,
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
}));
return messages;
}
if (raw.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: false,
}));
return messages;
}
// Assistant message
if (raw.message?.role === 'assistant' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
let partIndex = 0;
for (const part of raw.message.content) {
if (part.type === 'text' && part.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: part.text,
}));
} else if (part.type === 'tool_use') {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id,
}));
} else if (part.type === 'thinking' && part.thinking) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.thinking,
}));
}
partIndex++;
}
} else if (typeof raw.message.content === 'string') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message.content,
}));
}
return messages;
}
return messages;
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const claudeAdapter = {
normalizeMessage,
/**
* Fetch session history from JSONL files, returning normalized messages.
*/
async fetchHistory(sessionId, opts = {}) {
const { projectName, limit = null, offset = 0 } = opts;
if (!projectName) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
let result;
try {
result = await getSessionMessages(projectName, sessionId, limit, offset);
} catch (error) {
console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
// getSessionMessages returns either an array (no limit) or { messages, total, hasMore }
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
// First pass: collect tool results for attachment to tool_use messages
const toolResultMap = new Map();
for (const raw of rawMessages) {
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
toolResultMap.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
timestamp: raw.timestamp,
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
});
}
}
}
}
// Second pass: normalize all messages
const normalized = [];
for (const raw of rawMessages) {
const entries = normalizeMessage(raw, sessionId);
normalized.push(...entries);
}
// Attach tool results to their corresponding tool_use messages
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = {
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
isError: tr.isError,
toolUseResult: tr.toolUseResult,
};
msg.subagentTools = tr.subagentTools;
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
},
};

View File

@@ -1,136 +0,0 @@
/**
* Claude Provider Status
*
* Checks whether Claude Code CLI is installed and whether the user
* has valid authentication credentials.
*
* @module providers/claude/status
*/
import { execFileSync } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Claude Code CLI is installed and available.
* Uses CLAUDE_CLI_PATH env var if set, otherwise looks for 'claude' in PATH.
* @returns {boolean}
*/
export function checkInstalled() {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
try {
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
method: null,
error: 'Claude Code CLI is not installed'
};
}
const credentialsResult = await checkCredentials();
if (credentialsResult.authenticated) {
return {
installed,
authenticated: true,
email: credentialsResult.email || 'Authenticated',
method: credentialsResult.method || null,
error: null
};
}
return {
installed,
authenticated: false,
email: credentialsResult.email || null,
method: credentialsResult.method || null,
error: credentialsResult.error || 'Not authenticated'
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function loadSettingsEnv() {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
if (settings?.env && typeof settings.env === 'object') {
return settings.env;
}
} catch {
// Ignore missing or malformed settings.
}
return {};
}
/**
* Checks Claude authentication credentials.
*
* Priority 1: ANTHROPIC_API_KEY environment variable
* Priority 1b: ~/.claude/settings.json env values
* Priority 2: ~/.claude/.credentials.json OAuth tokens
*/
async function checkCredentials() {
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const settingsEnv = await loadSettingsEnv();
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
}
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8');
const creds = JSON.parse(content);
const oauth = creds.claudeAiOauth;
if (oauth && oauth.accessToken) {
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
if (!isExpired) {
return {
authenticated: true,
email: creds.email || creds.user || null,
method: 'credentials_file'
};
}
return {
authenticated: false,
email: creds.email || creds.user || null,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login'
};
}
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}

View File

@@ -1,248 +0,0 @@
/**
* Codex (OpenAI) provider adapter.
*
* Normalizes Codex SDK session history into NormalizedMessage format.
* @module adapters/codex
*/
import { getCodexSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'codex';
/**
* Normalize a raw Codex JSONL message into NormalizedMessage(s).
* @param {object} raw - A single parsed message from Codex JSONL
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
function normalizeCodexHistoryEntry(raw, sessionId) {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
// User message
if (raw.message?.role === 'user') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
: String(raw.message.content || '');
if (!content.trim()) return [];
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content,
})];
}
// Assistant message
if (raw.message?.role === 'assistant') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
: '';
if (!content.trim()) return [];
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content,
})];
}
// Thinking/reasoning
if (raw.type === 'thinking' || raw.isReasoning) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
}
// Tool use
if (raw.type === 'tool_use' || raw.toolName) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName || 'Unknown',
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
})];
}
// Tool result
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: Boolean(raw.isError),
})];
}
return [];
}
/**
* Normalize a raw Codex event (history JSONL or transformed SDK event) into NormalizedMessage(s).
* @param {object} raw - A history entry (has raw.message.role) or transformed SDK event (has raw.type)
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
// History format: has message.role
if (raw.message?.role) {
return normalizeCodexHistoryEntry(raw, sessionId);
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
// SDK event format (output of transformCodexEvent)
if (raw.type === 'item') {
switch (raw.itemType) {
case 'agent_message':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'text', role: 'assistant', content: raw.message?.content || '',
})];
case 'reasoning':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'thinking', content: raw.message?.content || '',
})];
case 'command_execution':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'Bash', toolInput: { command: raw.command },
toolId: baseId,
output: raw.output, exitCode: raw.exitCode, status: raw.status,
})];
case 'file_change':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'FileChanges', toolInput: raw.changes,
toolId: baseId, status: raw.status,
})];
case 'mcp_tool_call':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: raw.tool || 'MCP', toolInput: raw.arguments,
toolId: baseId, server: raw.server, result: raw.result,
error: raw.error, status: raw.status,
})];
case 'web_search':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'WebSearch', toolInput: { query: raw.query },
toolId: baseId,
})];
case 'todo_list':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'TodoList', toolInput: { items: raw.items },
toolId: baseId,
})];
case 'error':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'error', content: raw.message?.content || 'Unknown error',
})];
default:
// Unknown item type — pass through as generic tool_use
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: raw.itemType || 'Unknown',
toolInput: raw.item || raw, toolId: baseId,
})];
}
}
if (raw.type === 'turn_complete') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'complete',
})];
}
if (raw.type === 'turn_failed') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'error', content: raw.error?.message || 'Turn failed',
})];
}
return [];
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const codexAdapter = {
normalizeMessage,
/**
* Fetch session history from Codex JSONL files.
*/
async fetchHistory(sessionId, opts = {}) {
const { limit = null, offset = 0 } = opts;
let result;
try {
result = await getCodexSessionMessages(sessionId, limit, offset);
} catch (error) {
console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const tokenUsage = result.tokenUsage || null;
const normalized = [];
for (const raw of rawMessages) {
const entries = normalizeCodexHistoryEntry(raw, sessionId);
normalized.push(...entries);
}
// Attach tool results to tool_use messages
const toolResultMap = new Map();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = { content: tr.content, isError: tr.isError };
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
tokenUsage,
};
},
};

View File

@@ -1,78 +0,0 @@
/**
* Codex Provider Status
*
* Checks whether the user has valid Codex authentication credentials.
* Codex uses an SDK that makes direct API calls (no external binary),
* so installation check always returns true if the server is running.
*
* @module providers/codex/status
*/
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Codex is installed.
* Codex SDK is bundled with this application — no external binary needed.
* @returns {boolean}
*/
export function checkInstalled() {
return true;
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
const result = await checkCredentials();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function checkCredentials() {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await fs.readFile(authPath, 'utf8');
const auth = JSON.parse(content);
const tokens = auth.tokens || {};
if (tokens.id_token || tokens.access_token) {
let email = 'Authenticated';
if (tokens.id_token) {
try {
const parts = tokens.id_token.split('.');
if (parts.length >= 2) {
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
email = payload.email || payload.user || 'Authenticated';
}
} catch {
email = 'Authenticated';
}
}
return { authenticated: true, email };
}
if (auth.OPENAI_API_KEY) {
return { authenticated: true, email: 'API Key Auth' };
}
return { authenticated: false, email: null, error: 'No valid tokens found' };
} catch (error) {
if (error.code === 'ENOENT') {
return { authenticated: false, email: null, error: 'Codex not configured' };
}
return { authenticated: false, email: null, error: error.message };
}
}

View File

@@ -1,348 +0,0 @@
/**
* Cursor provider adapter.
*
* Normalizes Cursor CLI session history into NormalizedMessage format.
* @module adapters/cursor
*/
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'cursor';
/**
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
* and return sorted message blobs in chronological order.
* @param {string} sessionId
* @param {string} projectPath - Absolute project path (used to compute cwdId hash)
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
*/
async function loadCursorBlobs(sessionId, projectPath) {
// Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
const { default: Database } = await import('better-sqlite3');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
try {
const allBlobs = db.prepare('SELECT rowid, id, data FROM blobs').all();
const blobMap = new Map();
const parentRefs = new Map();
const childRefs = new Map();
const jsonBlobs = [];
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
if (blob.data && blob.data[0] === 0x7B) {
try {
const parsed = JSON.parse(blob.data.toString('utf8'));
jsonBlobs.push({ ...blob, parsed });
} catch {
// skip unparseable blobs
}
} else if (blob.data) {
const parents = [];
let i = 0;
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
for (const parentId of parents) {
if (!childRefs.has(parentId)) childRefs.set(parentId, []);
childRefs.get(parentId).push(blob.id);
}
}
}
}
// Topological sort (DFS)
const visited = new Set();
const sorted = [];
function visit(nodeId) {
if (visited.has(nodeId)) return;
visited.add(nodeId);
for (const pid of (parentRefs.get(nodeId) || [])) visit(pid);
const b = blobMap.get(nodeId);
if (b) sorted.push(b);
}
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) visit(blob.id);
}
for (const blob of allBlobs) visit(blob.id);
// Order JSON blobs by DAG appearance
const messageOrder = new Map();
let orderIndex = 0;
for (const blob of sorted) {
if (blob.data && blob.data[0] !== 0x7B) {
for (const jb of jsonBlobs) {
try {
const idBytes = Buffer.from(jb.id, 'hex');
if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) {
messageOrder.set(jb.id, orderIndex++);
}
} catch { /* skip */ }
}
}
}
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return oa !== ob ? oa - ob : a.rowid - b.rowid;
});
const messages = [];
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
const blob = sortedJsonBlobs[idx];
const parsed = blob.parsed;
if (!parsed) continue;
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') continue;
messages.push({
id: blob.id,
sequence: idx + 1,
rowid: blob.rowid,
content: parsed,
});
}
return messages;
} finally {
db.close();
}
}
/**
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
* @param {object|string} raw - A parsed NDJSON event or a raw text line
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
// Structured assistant message with content array
if (raw && typeof raw === 'object' && raw.type === 'assistant' && raw.message?.content?.[0]?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.message.content[0].text, sessionId, provider: PROVIDER })];
}
// Plain string line (non-JSON output)
if (typeof raw === 'string' && raw.trim()) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw, sessionId, provider: PROVIDER })];
}
return [];
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const cursorAdapter = {
normalizeMessage,
/**
* Fetch session history for Cursor from SQLite store.db.
*/
async fetchHistory(sessionId, opts = {}) {
const { projectPath = '', limit = null, offset = 0 } = opts;
try {
const blobs = await loadCursorBlobs(sessionId, projectPath);
const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId);
// Apply pagination
if (limit !== null && limit > 0) {
const start = offset;
const page = allNormalized.slice(start, start + limit);
return {
messages: page,
total: allNormalized.length,
hasMore: start + limit < allNormalized.length,
offset,
limit,
};
}
return {
messages: allNormalized,
total: allNormalized.length,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
// DB doesn't exist or is unreadable — return empty
console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
},
/**
* Normalize raw Cursor blob messages into NormalizedMessage[].
* @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
normalizeCursorBlobs(blobs, sessionId) {
const messages = [];
const toolUseMap = new Map();
// Use a fixed base timestamp so messages have stable, monotonically-increasing
// timestamps based on their sequence number rather than wall-clock time.
const baseTime = Date.now();
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
const content = blob.content;
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
const baseId = blob.id || generateMessageId('cursor');
try {
if (!content?.role || !content?.content) {
// Try nested message format
if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') continue;
const role = content.message.role === 'user' ? 'user' : 'assistant';
let text = '';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map(p => typeof p === 'string' ? p : p?.text || '')
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
if (text?.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
}
continue;
}
if (content.role === 'system') continue;
// Tool results
if (content.role === 'tool') {
const toolItems = Array.isArray(content.content) ? content.content : [];
for (const item of toolItems) {
if (item?.type !== 'tool-result') continue;
const toolCallId = item.toolCallId || content.id;
messages.push(createNormalizedMessage({
id: `${baseId}_tr`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: toolCallId,
content: item.result || '',
isError: false,
}));
}
continue;
}
const role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
const part = content.content[partIdx];
if (part?.type === 'text' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: part.text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
} else if (part?.type === 'reasoning' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.text,
}));
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'
? 'Edit' : (part.toolName || part.name || 'Unknown Tool');
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: part.args || part.input,
toolId,
}));
toolUseMap.set(toolId, messages[messages.length - 1]);
}
}
} else if (typeof content.content === 'string' && content.content.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: content.content,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
} catch (error) {
console.warn('Error normalizing cursor blob:', error);
}
}
// Attach tool results to tool_use messages
for (const msg of messages) {
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
const toolUse = toolUseMap.get(msg.toolId);
toolUse.toolResult = {
content: msg.content,
isError: msg.isError,
};
}
}
// Sort by sequence/rowid
messages.sort((a, b) => {
if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;
if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
return messages;
},
};

View File

@@ -1,128 +0,0 @@
/**
* Cursor Provider Status
*
* Checks whether cursor-agent CLI is installed and whether the user
* is logged in.
*
* @module providers/cursor/status
*/
import { execFileSync, spawn } from 'child_process';
/**
* Check if cursor-agent CLI is installed.
* @returns {boolean}
*/
export function checkInstalled() {
try {
execFileSync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
error: 'Cursor CLI is not installed'
};
}
const result = await checkCursorLogin();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
function checkCursorLogin() {
return new Promise((resolve) => {
let processCompleted = false;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
if (childProcess) {
childProcess.kill();
}
resolve({
authenticated: false,
email: null,
error: 'Command timeout'
});
}
}, 5000);
let childProcess;
try {
childProcess = spawn('cursor-agent', ['status']);
} catch {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch) {
resolve({ authenticated: true, email: emailMatch[1] });
} else if (stdout.includes('Logged in')) {
resolve({ authenticated: true, email: 'Logged in' });
} else {
resolve({ authenticated: false, email: null, error: 'Not logged in' });
}
} else {
resolve({ authenticated: false, email: null, error: stderr || 'Not logged in' });
}
});
childProcess.on('error', () => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
});
});
}

View File

@@ -1,186 +0,0 @@
/**
* Gemini provider adapter.
*
* Normalizes Gemini CLI session history into NormalizedMessage format.
* @module adapters/gemini
*/
import sessionManager from '../../sessionManager.js';
import { getGeminiCliSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'gemini';
/**
* Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s).
* Handles: message (delta/final), tool_use, tool_result, result, error.
* @param {object} raw - A parsed NDJSON event
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'message' && raw.role === 'assistant') {
const content = raw.content || '';
const msgs = [];
if (content) {
msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content }));
}
// If not a delta, also send stream_end
if (raw.delta !== true) {
msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' }));
}
return msgs;
}
if (raw.type === 'tool_use') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: raw.tool_name, toolInput: raw.parameters || {},
toolId: raw.tool_id || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_result', toolId: raw.tool_id || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: raw.status === 'error',
})];
}
if (raw.type === 'result') {
const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
if (raw.stats?.total_tokens) {
msgs.push(createNormalizedMessage({
sessionId, timestamp: ts, provider: PROVIDER,
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
}));
}
return msgs;
}
if (raw.type === 'error') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'error', content: raw.error || raw.message || 'Unknown Gemini streaming error',
})];
}
return [];
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const geminiAdapter = {
normalizeMessage,
/**
* Fetch session history for Gemini.
* First tries in-memory session manager, then falls back to CLI sessions on disk.
*/
async fetchHistory(sessionId, opts = {}) {
let rawMessages;
try {
rawMessages = sessionManager.getSessionMessages(sessionId);
// Fallback to Gemini CLI sessions on disk
if (rawMessages.length === 0) {
rawMessages = await getGeminiCliSessionMessages(sessionId);
}
} catch (error) {
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const normalized = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) continue;
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx];
if (part.type === 'text' && part.text) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
}));
}
}
// Attach tool results to tool_use messages
const toolResultMap = new Map();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = { content: tr.content, isError: tr.isError };
}
}
return {
messages: normalized,
total: normalized.length,
hasMore: false,
offset: 0,
limit: null,
};
},
};

View File

@@ -1,111 +0,0 @@
/**
* Gemini Provider Status
*
* Checks whether Gemini CLI is installed and whether the user
* has valid authentication credentials.
*
* @module providers/gemini/status
*/
import { execFileSync } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Gemini CLI is installed.
* Uses GEMINI_PATH env var if set, otherwise looks for 'gemini' in PATH.
* @returns {boolean}
*/
export function checkInstalled() {
const cliPath = process.env.GEMINI_PATH || 'gemini';
try {
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
error: 'Gemini CLI is not installed'
};
}
const result = await checkCredentials();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function checkCredentials() {
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth' };
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await fs.readFile(credsPath, 'utf8');
const creds = JSON.parse(content);
if (creds.access_token) {
let email = 'OAuth Session';
try {
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
if (tokenRes.ok) {
const tokenInfo = await tokenRes.json();
if (tokenInfo.email) {
email = tokenInfo.email;
}
} else if (!creds.refresh_token) {
return {
authenticated: false,
email: null,
error: 'Access token invalid and no refresh token found'
};
} else {
// Token might be expired but we have a refresh token, so CLI will refresh it
email = await getActiveAccountEmail() || email;
}
} catch {
// Network error, fallback to checking local accounts file
email = await getActiveAccountEmail() || email;
}
return { authenticated: true, email };
}
return { authenticated: false, email: null, error: 'No valid tokens found in oauth_creds' };
} catch {
return { authenticated: false, email: null, error: 'Gemini CLI not configured' };
}
}
async function getActiveAccountEmail() {
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
return accounts.active || null;
} catch {
return null;
}
}

View File

@@ -1,67 +0,0 @@
/**
* Provider Registry
*
* Centralizes provider adapter and status checker lookup. All code that needs
* a provider adapter or status checker should go through this registry instead
* of importing individual modules directly.
*
* @module providers/registry
*/
import { claudeAdapter } from './claude/adapter.js';
import { cursorAdapter } from './cursor/adapter.js';
import { codexAdapter } from './codex/adapter.js';
import { geminiAdapter } from './gemini/adapter.js';
import * as claudeStatus from './claude/status.js';
import * as cursorStatus from './cursor/status.js';
import * as codexStatus from './codex/status.js';
import * as geminiStatus from './gemini/status.js';
/**
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
* @typedef {import('./types.js').SessionProvider} SessionProvider
*/
/** @type {Map<string, ProviderAdapter>} */
const providers = new Map();
/** @type {Map<string, { checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> }>} */
const statusCheckers = new Map();
// Register built-in providers
providers.set('claude', claudeAdapter);
providers.set('cursor', cursorAdapter);
providers.set('codex', codexAdapter);
providers.set('gemini', geminiAdapter);
statusCheckers.set('claude', claudeStatus);
statusCheckers.set('cursor', cursorStatus);
statusCheckers.set('codex', codexStatus);
statusCheckers.set('gemini', geminiStatus);
/**
* Get a provider adapter by name.
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
* @returns {ProviderAdapter | undefined}
*/
export function getProvider(name) {
return providers.get(name);
}
/**
* Get a provider status checker by name.
* @param {string} name - Provider name
* @returns {{ checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> } | undefined}
*/
export function getStatusChecker(name) {
return statusCheckers.get(name);
}
/**
* Get all registered provider names.
* @returns {string[]}
*/
export function getAllProviders() {
return Array.from(providers.keys());
}

View File

@@ -1,132 +0,0 @@
/**
* Provider Types & Interface
*
* Defines the normalized message format and the provider adapter interface.
* All providers normalize their native formats into NormalizedMessage
* before sending over REST or WebSocket.
*
* @module providers/types
*/
// ─── Session Provider ────────────────────────────────────────────────────────
/**
* @typedef {'claude' | 'cursor' | 'codex' | 'gemini'} SessionProvider
*/
// ─── Message Kind ────────────────────────────────────────────────────────────
/**
* @typedef {'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end'
* | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled'
* | 'session_created' | 'interactive_prompt' | 'task_notification'} MessageKind
*/
// ─── NormalizedMessage ───────────────────────────────────────────────────────
/**
* @typedef {Object} NormalizedMessage
* @property {string} id - Unique message id (for dedup between server + realtime)
* @property {string} sessionId
* @property {string} timestamp - ISO 8601
* @property {SessionProvider} provider
* @property {MessageKind} kind
*
* Additional fields depending on kind:
* - text: role ('user'|'assistant'), content, images?
* - tool_use: toolName, toolInput, toolId
* - tool_result: toolId, content, isError
* - thinking: content
* - stream_delta: content
* - stream_end: (no extra fields)
* - error: content
* - complete: (no extra fields)
* - status: text, tokens?, canInterrupt?
* - permission_request: requestId, toolName, input, context?
* - permission_cancelled: requestId
* - session_created: newSessionId
* - interactive_prompt: content
* - task_notification: status, summary
*/
// ─── Fetch History ───────────────────────────────────────────────────────────
/**
* @typedef {Object} FetchHistoryOptions
* @property {string} [projectName] - Project name (required for Claude)
* @property {string} [projectPath] - Absolute project path (required for Cursor cwdId hash)
* @property {number|null} [limit] - Page size (null = all messages)
* @property {number} [offset] - Pagination offset (default: 0)
*/
/**
* @typedef {Object} FetchHistoryResult
* @property {NormalizedMessage[]} messages - Normalized messages
* @property {number} total - Total number of messages in the session
* @property {boolean} hasMore - Whether more messages exist before the current page
* @property {number} offset - Current offset
* @property {number|null} limit - Page size used
* @property {object} [tokenUsage] - Token usage data (provider-specific)
*/
// ─── Provider Status ────────────────────────────────────────────────────────
/**
* Result of a provider status check (installation + authentication).
*
* @typedef {Object} ProviderStatus
* @property {boolean} installed - Whether the provider's CLI/SDK is available
* @property {boolean} authenticated - Whether valid credentials exist
* @property {string|null} email - User email or auth method identifier
* @property {string|null} [method] - Auth method (e.g. 'api_key', 'credentials_file')
* @property {string|null} [error] - Error message if not installed or not authenticated
*/
// ─── Provider Adapter Interface ──────────────────────────────────────────────
/**
* Every provider adapter MUST implement this interface.
*
* @typedef {Object} ProviderAdapter
*
* @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise<FetchHistoryResult>} fetchHistory
* Read persisted session messages from disk/database and return them as NormalizedMessage[].
* The backend calls this from the unified GET /api/sessions/:id/messages endpoint.
*
* Provider implementations:
* - Claude: reads ~/.claude/projects/{projectName}/*.jsonl
* - Cursor: reads from SQLite store.db (via normalizeCursorBlobs helper)
* - Codex: reads ~/.codex/sessions/*.jsonl
* - Gemini: reads from in-memory sessionManager or ~/.gemini/tmp/ JSON files
*
* @property {(raw: any, sessionId: string) => NormalizedMessage[]} normalizeMessage
* Normalize a provider-specific event (JSONL entry or live SDK event) into NormalizedMessage[].
* Used by provider files to convert both history and realtime events.
*/
// ─── Runtime Helpers ─────────────────────────────────────────────────────────
/**
* Generate a unique message ID.
* Uses crypto.randomUUID() to avoid collisions across server restarts and workers.
* @param {string} [prefix='msg'] - Optional prefix
* @returns {string}
*/
export function generateMessageId(prefix = 'msg') {
return `${prefix}_${crypto.randomUUID()}`;
}
/**
* Create a NormalizedMessage with common fields pre-filled.
* @param {Partial<NormalizedMessage> & {kind: MessageKind, provider: SessionProvider}} fields
* @returns {NormalizedMessage}
*/
export function createNormalizedMessage(fields) {
return {
...fields,
id: fields.id || generateMessageId(fields.kind),
sessionId: fields.sessionId || '',
timestamp: fields.timestamp || new Date().toISOString(),
provider: fields.provider,
};
}

View File

@@ -1,29 +0,0 @@
/**
* Shared provider utilities.
*
* @module providers/utils
*/
/**
* Prefixes that indicate internal/system content which should be hidden from the UI.
* @type {readonly string[]}
*/
export const INTERNAL_CONTENT_PREFIXES = Object.freeze([
'<command-name>',
'<command-message>',
'<command-args>',
'<local-command-stdout>',
'<system-reminder>',
'Caveat:',
'This session is being continued from a previous',
'[Request interrupted',
]);
/**
* Check if user text content is internal/system that should be skipped.
* @param {string} content
* @returns {boolean}
*/
export function isInternalContent(content) {
return INTERNAL_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix));
}

View File

@@ -4,8 +4,7 @@ import path from 'path';
import os from 'os';
import { promises as fs } from 'fs';
import crypto from 'crypto';
import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';
import { addProjectManually } from '../projects.js';
import { userDb, apiKeysDb, githubTokensDb, projectsDb } from '../modules/database/index.js';
import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
@@ -13,6 +12,7 @@ import { spawnGemini } from '../gemini-cli.js';
import { Octokit } from '@octokit/rest';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { IS_PLATFORM } from '../constants/config.js';
import { normalizeProjectPath } from '../shared/utils.js';
const router = express.Router();
@@ -890,7 +890,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
} else {
// Use existing project path
finalProjectPath = path.resolve(projectPath);
finalProjectPath = normalizeProjectPath(path.resolve(projectPath));
// Verify the path exists
try {
@@ -900,19 +900,14 @@ router.post('/', validateExternalApiKey, async (req, res) => {
}
}
// Register the project (or use existing registration)
let project;
try {
project = await addProjectManually(finalProjectPath);
console.log('📦 Project registered:', project);
} catch (error) {
// If project already exists, that's fine - continue with the existing registration
if (error.message && error.message.includes('Project already configured')) {
console.log('📦 Using existing project registration for:', finalProjectPath);
project = { path: finalProjectPath };
} else {
throw error;
}
finalProjectPath = normalizeProjectPath(finalProjectPath);
// Register project path in DB (or reuse existing active registration)
const registrationResult = projectsDb.createProjectPath(finalProjectPath, null);
if (registrationResult.outcome === 'active_conflict') {
console.log('Project registration already exists for:', finalProjectPath);
} else {
console.log('Project registered:', registrationResult.project);
}
// Set up writer based on streaming mode

View File

@@ -1,9 +1,11 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { userDb, db } from '../database/db.js';
import { userDb } from '../modules/database/index.js';
import { getConnection } from '../modules/database/connection.js';
import { generateToken, authenticateToken } from '../middleware/auth.js';
const router = express.Router();
const db = getConnection();
// Check auth status and setup requirements
router.get('/status', async (req, res) => {
@@ -132,4 +134,4 @@ router.post('/logout', authenticateToken, (req, res) => {
res.json({ success: true, message: 'Logged out successfully' });
});
export default router;
export default router;

View File

@@ -1,27 +0,0 @@
/**
* CLI Auth Routes
*
* Thin router that delegates to per-provider status checkers
* registered in the provider registry.
*
* @module routes/cli-auth
*/
import express from 'express';
import { getAllProviders, getStatusChecker } from '../providers/registry.js';
const router = express.Router();
for (const provider of getAllProviders()) {
router.get(`/${provider}/status`, async (req, res) => {
try {
const checker = getStatusChecker(provider);
res.json(await checker.checkStatus());
} catch (error) {
console.error(`Error checking ${provider} status:`, error);
res.status(500).json({ authenticated: false, error: error.message });
}
});
}
export default router;

View File

@@ -1,329 +0,0 @@
import express from 'express';
import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import TOML from '@iarna/toml';
import { getCodexSessions, deleteCodexSession } from '../projects.js';
import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
const router = express.Router();
function createCliResponder(res) {
let responded = false;
return (status, payload) => {
if (responded || res.headersSent) {
return;
}
responded = true;
res.status(status).json(payload);
};
}
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
const content = await fs.readFile(configPath, 'utf8');
const config = TOML.parse(content);
res.json({
success: true,
config: {
model: config.model || null,
mcpServers: config.mcp_servers || {},
approvalMode: config.approval_mode || 'suggest'
}
});
} catch (error) {
if (error.code === 'ENOENT') {
res.json({
success: true,
config: {
model: null,
mcpServers: {},
approvalMode: 'suggest'
}
});
} else {
console.error('Error reading Codex config:', error);
res.status(500).json({ success: false, error: error.message });
}
}
});
router.get('/sessions', async (req, res) => {
try {
const { projectPath } = req.query;
if (!projectPath) {
return res.status(400).json({ success: false, error: 'projectPath query parameter required' });
}
const sessions = await getCodexSessions(projectPath);
applyCustomSessionNames(sessions, 'codex');
res.json({ success: true, sessions });
} catch (error) {
console.error('Error fetching Codex sessions:', error);
res.status(500).json({ success: false, error: error.message });
}
});
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
await deleteCodexSession(sessionId);
sessionNamesDb.deleteName(sessionId, 'codex');
res.json({ success: true });
} catch (error) {
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
// MCP Server Management Routes
router.get('/mcp/cli/list', async (req, res) => {
try {
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} else {
respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
}
});
router.post('/mcp/cli/add', async (req, res) => {
try {
const { name, command, args = [], env = {} } = req.body;
if (!name || !command) {
return res.status(400).json({ error: 'name and command are required' });
}
// Build: codex mcp add <name> [-e KEY=VAL]... -- <command> [args...]
let cliArgs = ['mcp', 'add', name];
Object.entries(env).forEach(([key, value]) => {
cliArgs.push('-e', `${key}=${value}`);
});
cliArgs.push('--', command);
if (args && args.length > 0) {
cliArgs.push(...args);
}
const respond = createCliResponder(res);
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else {
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
}
});
router.delete('/mcp/cli/remove/:name', async (req, res) => {
try {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else {
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
}
});
router.get('/mcp/cli/get/:name', async (req, res) => {
try {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} else {
respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
}
});
router.get('/mcp/config/read', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
let configData = null;
try {
const fileContent = await fs.readFile(configPath, 'utf8');
configData = TOML.parse(fileContent);
} catch (error) {
// Config file doesn't exist
}
if (!configData) {
return res.json({ success: true, configPath, servers: [] }); }
const servers = [];
if (configData.mcp_servers && typeof configData.mcp_servers === 'object') {
for (const [name, config] of Object.entries(configData.mcp_servers)) {
servers.push({
id: name,
name: name,
type: 'stdio',
scope: 'user',
config: {
command: config.command || '',
args: config.args || [],
env: config.env || {}
},
raw: config
});
}
}
res.json({ success: true, configPath, servers });
} catch (error) {
res.status(500).json({ error: 'Failed to read Codex configuration', details: error.message });
}
});
function parseCodexListOutput(output) {
const servers = [];
const lines = output.split('\n').filter(line => line.trim());
for (const line of lines) {
if (line.includes(':')) {
const colonIndex = line.indexOf(':');
const name = line.substring(0, colonIndex).trim();
if (!name) continue;
const rest = line.substring(colonIndex + 1).trim();
let description = rest;
let status = 'unknown';
if (rest.includes('✓') || rest.includes('✗')) {
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
if (statusMatch) {
description = statusMatch[1].trim();
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
}
}
servers.push({ name, type: 'stdio', status, description });
}
}
return servers;
}
function parseCodexGetOutput(output) {
try {
const jsonMatch = output.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
const server = { raw_output: output };
const lines = output.split('\n');
for (const line of lines) {
if (line.includes('Name:')) server.name = line.split(':')[1]?.trim();
else if (line.includes('Type:')) server.type = line.split(':')[1]?.trim();
else if (line.includes('Command:')) server.command = line.split(':')[1]?.trim();
}
return server;
} catch (error) {
return { raw_output: output, parse_error: error.message };
}
}
export default router;

View File

@@ -320,7 +320,7 @@ Custom commands can be created in:
packageName,
uptime: uptimeFormatted,
uptimeSeconds: Math.floor(uptime),
model: context?.model || 'claude-sonnet-4.5',
model: context?.model || CLAUDE_MODELS.DEFAULT,
provider: context?.provider || 'claude',
nodeVersion: process.version,
platform: process.platform
@@ -451,55 +451,6 @@ router.post('/list', async (req, res) => {
}
});
/**
* POST /api/commands/load
* Load a specific command file and return its content and metadata
*/
router.post('/load', async (req, res) => {
try {
const { commandPath } = req.body;
if (!commandPath) {
return res.status(400).json({
error: 'Command path is required'
});
}
// Security: Prevent path traversal
const resolvedPath = path.resolve(commandPath);
if (!resolvedPath.startsWith(path.resolve(os.homedir())) &&
!resolvedPath.includes('.claude/commands')) {
return res.status(403).json({
error: 'Access denied',
message: 'Command must be in .claude/commands directory'
});
}
// Read and parse the command file
const content = await fs.readFile(commandPath, 'utf8');
const { data: metadata, content: commandContent } = parseFrontmatter(content);
res.json({
path: commandPath,
metadata,
content: commandContent
});
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({
error: 'Command not found',
message: `Command file not found: ${req.body.commandPath}`
});
}
console.error('Error loading command:', error);
res.status(500).json({
error: 'Failed to load command',
message: error.message
});
}
});
/**
* POST /api/commands/execute
* Execute a command with argument replacement

View File

@@ -2,563 +2,51 @@ import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import Database from 'better-sqlite3';
import crypto from 'crypto';
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
import { applyCustomSessionNames } from '../database/db.js';
const router = express.Router();
// GET /api/cursor/config - Read Cursor CLI configuration
// GET /api/cursor/config - Read Cursor CLI configuration.
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
try {
const configContent = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configContent);
res.json({
success: true,
config: config,
path: configPath
config,
path: configPath,
});
} catch (error) {
// Config doesn't exist or is invalid
// Config doesn't exist or is invalid, so return the UI default shape.
console.log('Cursor config not found or invalid:', error.message);
// Return default config
res.json({
success: true,
config: {
version: 1,
model: {
modelId: CURSOR_MODELS.DEFAULT,
displayName: "GPT-5"
displayName: 'GPT-5',
},
permissions: {
allow: [],
deny: []
}
deny: [],
},
},
isDefault: true
isDefault: true,
});
}
} catch (error) {
console.error('Error reading Cursor config:', error);
res.status(500).json({
error: 'Failed to read Cursor configuration',
details: error.message
res.status(500).json({
error: 'Failed to read Cursor configuration',
details: error.message,
});
}
});
// POST /api/cursor/config - Update Cursor CLI configuration
router.post('/config', async (req, res) => {
try {
const { permissions, model } = req.body;
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
// Read existing config or create default
let config = {
version: 1,
editor: {
vimMode: false
},
hasChangedDefaultModel: false,
privacyCache: {
ghostMode: false,
privacyMode: 3,
updatedAt: Date.now()
}
};
try {
const existing = await fs.readFile(configPath, 'utf8');
config = JSON.parse(existing);
} catch (error) {
// Config doesn't exist, use defaults
console.log('Creating new Cursor config');
}
// Update permissions if provided
if (permissions) {
config.permissions = {
allow: permissions.allow || [],
deny: permissions.deny || []
};
}
// Update model if provided
if (model) {
config.model = model;
config.hasChangedDefaultModel = true;
}
// Ensure directory exists
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
// Write updated config
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
res.json({
success: true,
config: config,
message: 'Cursor configuration updated successfully'
});
} catch (error) {
console.error('Error updating Cursor config:', error);
res.status(500).json({
error: 'Failed to update Cursor configuration',
details: error.message
});
}
});
// GET /api/cursor/mcp - Read Cursor MCP servers configuration
router.get('/mcp', async (req, res) => {
try {
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
try {
const mcpContent = await fs.readFile(mcpPath, 'utf8');
const mcpConfig = JSON.parse(mcpContent);
// Convert to UI-friendly format
const servers = [];
if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') {
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
const server = {
id: name,
name: name,
type: 'stdio',
scope: 'cursor',
config: {},
raw: config
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
res.json({
success: true,
servers: servers,
path: mcpPath
});
} catch (error) {
// MCP config doesn't exist
console.log('Cursor MCP config not found:', error.message);
res.json({
success: true,
servers: [],
isDefault: true
});
}
} catch (error) {
console.error('Error reading Cursor MCP config:', error);
res.status(500).json({
error: 'Failed to read Cursor MCP configuration',
details: error.message
});
}
});
// POST /api/cursor/mcp/add - Add MCP server to Cursor configuration
router.post('/mcp/add', async (req, res) => {
try {
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(` Adding MCP server to Cursor config: ${name}`);
// Read existing config or create new
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
if (!mcpConfig.mcpServers) {
mcpConfig.mcpServers = {};
}
} catch (error) {
console.log('Creating new Cursor MCP config');
}
// Build server config based on type
let serverConfig = {};
if (type === 'stdio') {
serverConfig = {
command: command,
args: args,
env: env
};
} else if (type === 'http' || type === 'sse') {
serverConfig = {
url: url,
transport: type,
headers: headers
};
}
// Add server to config
mcpConfig.mcpServers[name] = serverConfig;
// Ensure directory exists
const mcpDir = path.dirname(mcpPath);
await fs.mkdir(mcpDir, { recursive: true });
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" added to Cursor configuration`,
config: mcpConfig
});
} catch (error) {
console.error('Error adding MCP server to Cursor:', error);
res.status(500).json({
error: 'Failed to add MCP server',
details: error.message
});
}
});
// DELETE /api/cursor/mcp/:name - Remove MCP server from Cursor configuration
router.delete('/mcp/:name', async (req, res) => {
try {
const { name } = req.params;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(`🗑️ Removing MCP server from Cursor config: ${name}`);
// Read existing config
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
} catch (error) {
return res.status(404).json({
error: 'Cursor MCP configuration not found'
});
}
// Check if server exists
if (!mcpConfig.mcpServers || !mcpConfig.mcpServers[name]) {
return res.status(404).json({
error: `MCP server "${name}" not found in Cursor configuration`
});
}
// Remove server from config
delete mcpConfig.mcpServers[name];
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" removed from Cursor configuration`,
config: mcpConfig
});
} catch (error) {
console.error('Error removing MCP server from Cursor:', error);
res.status(500).json({
error: 'Failed to remove MCP server',
details: error.message
});
}
});
// POST /api/cursor/mcp/add-json - Add MCP server using JSON format
router.post('/mcp/add-json', async (req, res) => {
try {
const { name, jsonConfig } = req.body;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(` Adding MCP server to Cursor config via JSON: ${name}`);
// Validate and parse JSON config
let parsedConfig;
try {
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
} catch (parseError) {
return res.status(400).json({
error: 'Invalid JSON configuration',
details: parseError.message
});
}
// Read existing config or create new
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
if (!mcpConfig.mcpServers) {
mcpConfig.mcpServers = {};
}
} catch (error) {
console.log('Creating new Cursor MCP config');
}
// Add server to config
mcpConfig.mcpServers[name] = parsedConfig;
// Ensure directory exists
const mcpDir = path.dirname(mcpPath);
await fs.mkdir(mcpDir, { recursive: true });
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" added to Cursor configuration via JSON`,
config: mcpConfig
});
} catch (error) {
console.error('Error adding MCP server to Cursor via JSON:', error);
res.status(500).json({
error: 'Failed to add MCP server',
details: error.message
});
}
});
// GET /api/cursor/sessions - Get Cursor sessions from SQLite database
router.get('/sessions', async (req, res) => {
try {
const { projectPath } = req.query;
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
// Check if the directory exists
try {
await fs.access(cursorChatsPath);
} catch (error) {
// No sessions for this project
return res.json({
success: true,
sessions: [],
cwdId: cwdId,
path: cursorChatsPath
});
}
// List all session directories
const sessionDirs = await fs.readdir(cursorChatsPath);
const sessions = [];
for (const sessionId of sessionDirs) {
const sessionPath = path.join(cursorChatsPath, sessionId);
const storeDbPath = path.join(sessionPath, 'store.db');
let dbStatMtimeMs = null;
try {
// Check if store.db exists
await fs.access(storeDbPath);
// Capture store.db mtime as a reliable fallback timestamp (last activity)
try {
const stat = await fs.stat(storeDbPath);
dbStatMtimeMs = stat.mtimeMs;
} catch (_) {}
// Open SQLite database
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
// Get metadata from meta table
const metaRows = db.prepare('SELECT key, value FROM meta').all();
let sessionData = {
id: sessionId,
name: 'Untitled Session',
createdAt: null,
mode: null,
projectPath: projectPath,
lastMessage: null,
messageCount: 0
};
// Parse meta table entries
for (const row of metaRows) {
if (row.value) {
try {
// Try to decode as hex-encoded JSON
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
if (hexMatch) {
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
const data = JSON.parse(jsonStr);
if (row.key === 'agent') {
sessionData.name = data.name || sessionData.name;
// Normalize createdAt to ISO string in milliseconds
let createdAt = data.createdAt;
if (typeof createdAt === 'number') {
if (createdAt < 1e12) {
createdAt = createdAt * 1000; // seconds -> ms
}
sessionData.createdAt = new Date(createdAt).toISOString();
} else if (typeof createdAt === 'string') {
const n = Number(createdAt);
if (!Number.isNaN(n)) {
const ms = n < 1e12 ? n * 1000 : n;
sessionData.createdAt = new Date(ms).toISOString();
} else {
// Assume it's already an ISO/date string
const d = new Date(createdAt);
sessionData.createdAt = isNaN(d.getTime()) ? null : d.toISOString();
}
} else {
sessionData.createdAt = sessionData.createdAt || null;
}
sessionData.mode = data.mode;
sessionData.agentId = data.agentId;
sessionData.latestRootBlobId = data.latestRootBlobId;
}
} else {
// If not hex, use raw value for simple keys
if (row.key === 'name') {
sessionData.name = row.value.toString();
}
}
} catch (e) {
console.log(`Could not parse meta value for key ${row.key}:`, e.message);
}
}
}
// Get message count from JSON blobs only (actual messages, not DAG structure)
try {
const blobCount = db.prepare(`SELECT COUNT(*) as count FROM blobs WHERE substr(data, 1, 1) = X'7B'`).get();
sessionData.messageCount = blobCount.count;
// Get the most recent JSON blob for preview (actual message, not DAG structure)
const lastBlob = db.prepare(`SELECT data FROM blobs WHERE substr(data, 1, 1) = X'7B' ORDER BY rowid DESC LIMIT 1`).get();
if (lastBlob && lastBlob.data) {
try {
// Try to extract readable preview from blob (may contain binary with embedded JSON)
const raw = lastBlob.data.toString('utf8');
let preview = '';
// Attempt direct JSON parse
try {
const parsed = JSON.parse(raw);
if (parsed?.content) {
if (Array.isArray(parsed.content)) {
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
preview = firstText;
} else if (typeof parsed.content === 'string') {
preview = parsed.content;
}
}
} catch (_) {}
if (!preview) {
// Strip non-printable and try to find JSON chunk
const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
const s = cleaned;
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start !== -1 && end > start) {
const jsonStr = s.slice(start, end + 1);
try {
const parsed = JSON.parse(jsonStr);
if (parsed?.content) {
if (Array.isArray(parsed.content)) {
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
preview = firstText;
} else if (typeof parsed.content === 'string') {
preview = parsed.content;
}
}
} catch (_) {
preview = s;
}
} else {
preview = s;
}
}
if (preview && preview.length > 0) {
sessionData.lastMessage = preview.substring(0, 100) + (preview.length > 100 ? '...' : '');
}
} catch (e) {
console.log('Could not parse blob data:', e.message);
}
}
} catch (e) {
console.log('Could not read blobs:', e.message);
}
db.close();
// Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime
if (!sessionData.createdAt) {
if (dbStatMtimeMs && Number.isFinite(dbStatMtimeMs)) {
sessionData.createdAt = new Date(dbStatMtimeMs).toISOString();
}
}
sessions.push(sessionData);
} catch (error) {
console.log(`Could not read session ${sessionId}:`, error.message);
}
}
// Fallback: ensure createdAt is a valid ISO string (use session directory mtime as last resort)
for (const s of sessions) {
if (!s.createdAt) {
try {
const sessionDir = path.join(cursorChatsPath, s.id);
const st = await fs.stat(sessionDir);
s.createdAt = new Date(st.mtimeMs).toISOString();
} catch {
s.createdAt = new Date().toISOString();
}
}
}
// Sort sessions by creation date (newest first)
sessions.sort((a, b) => {
if (!a.createdAt) return 1;
if (!b.createdAt) return -1;
return new Date(b.createdAt) - new Date(a.createdAt);
});
applyCustomSessionNames(sessions, 'cursor');
res.json({
success: true,
sessions: sessions,
cwdId: cwdId,
path: cursorChatsPath
});
} catch (error) {
console.error('Error reading Cursor sessions:', error);
res.status(500).json({
error: 'Failed to read Cursor sessions',
details: error.message
});
}
});
export default router;
export default router;

View File

@@ -1,6 +1,7 @@
import express from 'express';
import sessionManager from '../sessionManager.js';
import { sessionNamesDb } from '../database/db.js';
import { sessionsDb } from '../modules/database/index.js';
const router = express.Router();
@@ -13,7 +14,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
}
await sessionManager.deleteSession(sessionId);
sessionNamesDb.deleteName(sessionId, 'gemini');
sessionsDb.deleteSessionById(sessionId);
res.json({ success: true });
} catch (error) {
console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);

View File

@@ -2,7 +2,7 @@ import express from 'express';
import { spawn } from 'child_process';
import path from 'path';
import { promises as fs } from 'fs';
import { extractProjectDirectory } from '../projects.js';
import { projectsDb } from '../modules/database/index.js';
import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
@@ -101,14 +101,19 @@ function validateProjectPath(projectPath) {
return resolved;
}
// Helper function to get the actual project path from the encoded project name
async function getActualProjectPath(projectName) {
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
throw new Error(`Unable to resolve project path for "${projectName}"`);
/**
* Resolve the absolute project directory for a given DB `projectId`.
*
* After the projectName → projectId migration, every git endpoint receives
* the DB primary key (`project` query/body param). The legacy filesystem
* resolver that walked Claude's JSONL history is no longer used here; the
* path comes straight from the `projects` table and is then sanity-checked
* by `validateProjectPath` before any `git` command runs against it.
*/
async function getActualProjectPath(projectId) {
const projectPath = await projectsDb.getProjectPathById(projectId);
if (!projectPath) {
throw new Error(`Unable to resolve project path for "${projectId}"`);
}
return validateProjectPath(projectPath);
}
@@ -292,7 +297,7 @@ router.get('/status', async (req, res) => {
const { project } = req.query;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
return res.status(400).json({ error: 'Project id is required' });
}
try {
@@ -355,7 +360,7 @@ router.get('/diff', async (req, res) => {
const { project, file } = req.query;
if (!project || !file) {
return res.status(400).json({ error: 'Project name and file path are required' });
return res.status(400).json({ error: 'Project id and file path are required' });
}
try {
@@ -438,7 +443,7 @@ router.get('/file-with-diff', async (req, res) => {
const { project, file } = req.query;
if (!project || !file) {
return res.status(400).json({ error: 'Project name and file path are required' });
return res.status(400).json({ error: 'Project id and file path are required' });
}
try {
@@ -518,7 +523,7 @@ router.post('/initial-commit', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
return res.status(400).json({ error: 'Project id is required' });
}
try {
@@ -593,7 +598,7 @@ router.post('/revert-local-commit', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
return res.status(400).json({ error: 'Project id is required' });
}
try {
@@ -640,7 +645,7 @@ router.get('/branches', async (req, res) => {
const { project } = req.query;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
return res.status(400).json({ error: 'Project id is required' });
}
try {
@@ -684,7 +689,7 @@ router.post('/checkout', async (req, res) => {
const { project, branch } = req.body;
if (!project || !branch) {
return res.status(400).json({ error: 'Project name and branch are required' });
return res.status(400).json({ error: 'Project id and branch are required' });
}
try {
@@ -706,7 +711,7 @@ router.post('/create-branch', async (req, res) => {
const { project, branch } = req.body;
if (!project || !branch) {
return res.status(400).json({ error: 'Project name and branch name are required' });
return res.status(400).json({ error: 'Project id and branch name are required' });
}
try {
@@ -728,7 +733,7 @@ router.post('/delete-branch', async (req, res) => {
const { project, branch } = req.body;
if (!project || !branch) {
return res.status(400).json({ error: 'Project name and branch name are required' });
return res.status(400).json({ error: 'Project id and branch name are required' });
}
try {
@@ -754,7 +759,7 @@ router.get('/commits', async (req, res) => {
const { project, limit = 10 } = req.query;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
return res.status(400).json({ error: 'Project id is required' });
}
try {
@@ -811,7 +816,7 @@ router.get('/commit-diff', async (req, res) => {
const { project, commit } = req.query;
if (!project || !commit) {
return res.status(400).json({ error: 'Project name and commit hash are required' });
return res.status(400).json({ error: 'Project id and commit hash are required' });
}
try {
@@ -843,7 +848,7 @@ router.post('/generate-commit-message', async (req, res) => {
const { project, files, provider = 'claude' } = req.body;
if (!project || !files || files.length === 0) {
return res.status(400).json({ error: 'Project name and files are required' });
return res.status(400).json({ error: 'Project id and files are required' });
}
// Validate provider
@@ -1048,7 +1053,7 @@ router.get('/remote-status', async (req, res) => {
const { project } = req.query;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
return res.status(400).json({ error: 'Project id is required' });
}
try {
@@ -1126,7 +1131,7 @@ router.post('/fetch', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
return res.status(400).json({ error: 'Project id is required' });
}
try {
@@ -1167,7 +1172,7 @@ router.post('/pull', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
return res.status(400).json({ error: 'Project id is required' });
}
try {
@@ -1235,7 +1240,7 @@ router.post('/push', async (req, res) => {
const { project } = req.body;
if (!project) {
return res.status(400).json({ error: 'Project name is required' });
return res.status(400).json({ error: 'Project id is required' });
}
try {
@@ -1306,7 +1311,7 @@ router.post('/publish', async (req, res) => {
const { project, branch } = req.body;
if (!project || !branch) {
return res.status(400).json({ error: 'Project name and branch are required' });
return res.status(400).json({ error: 'Project id and branch are required' });
}
try {
@@ -1385,7 +1390,7 @@ router.post('/discard', async (req, res) => {
const { project, file } = req.body;
if (!project || !file) {
return res.status(400).json({ error: 'Project name and file path are required' });
return res.status(400).json({ error: 'Project id and file path are required' });
}
try {
@@ -1439,7 +1444,7 @@ router.post('/delete-untracked', async (req, res) => {
const { project, file } = req.body;
if (!project || !file) {
return res.status(400).json({ error: 'Project name and file path are required' });
return res.status(400).json({ error: 'Project id and file path are required' });
}
try {

View File

@@ -7,7 +7,7 @@
*/
import express from 'express';
import { detectTaskMasterMCPServer, getAllMCPServers } from '../utils/mcp-detector.js';
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
const router = express.Router();
@@ -28,21 +28,4 @@ router.get('/taskmaster-server', async (req, res) => {
}
});
/**
* GET /api/mcp-utils/all-servers
* Get all configured MCP servers
*/
router.get('/all-servers', async (req, res) => {
try {
const result = await getAllMCPServers();
res.json(result);
} catch (error) {
console.error('MCP servers detection error:', error);
res.status(500).json({
error: 'Failed to get MCP servers',
message: error.message
});
}
});
export default router;
export default router;

Some files were not shown because too many files have changed in this diff Show More