Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
475 changes: 471 additions & 4 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"scripts": {
"dev": "NODE_ENV=development tsx watch src/server.ts",
"dev:api": "NODE_ENV=development API_ONLY=true tsx watch src/server.ts",
"dev:web": "NODE_ENV=development npx vite",
"clean": "rm -rf dist",
"build": "npm run clean && NODE_ENV=production npm run build:web && tsc && tsc-alias && npm run build:mcp",
Expand All @@ -54,7 +55,7 @@
"postinstall": "node scripts/postinstall.js"
},
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.70",
"@anthropic-ai/claude-code": "^2.1.19",
"@anthropic-ai/sdk": "^0.54.0",
"@google/genai": "^1.11.0",
"@modelcontextprotocol/sdk": "^1.17.0",
Expand Down Expand Up @@ -133,7 +134,9 @@
"vitest": "^3.2.4"
},
"optionalDependencies": {
"@tailwindcss/oxide-linux-arm64-gnu": "^4.1.18",
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.11",
"lightningcss-linux-arm64-gnu": "^1.31.1",
"lightningcss-linux-x64-gnu": "^1.30.1"
}
}
31 changes: 17 additions & 14 deletions src/cui-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,12 @@ export class CUIServer {
this.processManager.setMCPConfigPath(mcpConfigPath);
this.logger.debug('MCP config generated and set', { path: mcpConfigPath });
} catch (error) {
const isTestEnv = process.env.NODE_ENV === 'test';

if (isTestEnv) {
this.logger.warn('MCP config generation failed in test environment, proceeding without MCP', {
error: error instanceof Error ? error.message : String(error)
const isNonProduction = process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development';

if (isNonProduction) {
this.logger.warn('MCP config generation failed, proceeding without MCP', {
error: error instanceof Error ? error.message : String(error),
env: process.env.NODE_ENV
});
// Don't set MCP config path - conversations will run without MCP
} else {
Expand Down Expand Up @@ -253,20 +254,22 @@ export class CUIServer {

// Start Express server
const isDev = process.env.NODE_ENV === 'development';
this.logger.debug('Creating HTTP server listener', {
useViteExpress: isDev,
environment: process.env.NODE_ENV
const apiOnly = process.env.API_ONLY === 'true'; // Skip Vite for API-only mode
this.logger.debug('Creating HTTP server listener', {
useViteExpress: isDev && !apiOnly,
apiOnly,
environment: process.env.NODE_ENV
});
// Import ViteExpress dynamically if in development mode
if (isDev && !ViteExpress) {

// Import ViteExpress dynamically if in development mode (and not API-only)
if (isDev && !apiOnly && !ViteExpress) {
const viteExpressModule = await import('vite-express');
ViteExpress = viteExpressModule.default;
}

await new Promise<void>((resolve, reject) => {
// Use ViteExpress only in development
if (isDev && ViteExpress) {
// Use ViteExpress only in development (and not API-only)
if (isDev && !apiOnly && ViteExpress) {
try {
this.server = this.app.listen(this.port, this.host, () => {
this.logger.debug('Server successfully bound to port (dev mode)', {
Expand Down
18 changes: 12 additions & 6 deletions src/routes/conversation.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,10 @@ export function createConversationRoutes(

const { streamingId, systemInit } = await processManager.startConversation(conversationConfig);

// Update original session with continuation session ID if resuming
if (req.body.resumedSessionId) {
// Update original session with continuation session ID if resuming AND session ID changed
// Note: Claude CLI 2.x reuses the same session ID by default when resuming
// Claude CLI 1.x may create a new session ID when resuming
if (req.body.resumedSessionId && systemInit.session_id !== req.body.resumedSessionId) {
try {
await sessionInfoService.updateSessionInfo(req.body.resumedSessionId, {
continuation_session_id: systemInit.session_id
Expand All @@ -131,8 +133,11 @@ export function createConversationRoutes(
error: error instanceof Error ? error.message : String(error)
});
}

// Register the resumed session with conversation status manager including previous messages
}

// Register the resumed session with conversation status manager including previous messages
// This must happen regardless of whether session ID changed (Claude 2.x reuses same ID)
if (req.body.resumedSessionId) {
try {
conversationStatusManager.registerActiveSession(
streamingId,
Expand All @@ -146,9 +151,10 @@ export function createConversationRoutes(
);
logger.debug('Registered resumed session with inherited messages', {
requestId,
newSessionId: systemInit.session_id,
sessionId: systemInit.session_id,
streamingId,
inheritedMessageCount: previousMessages.length
inheritedMessageCount: previousMessages.length,
sessionIdChanged: systemInit.session_id !== req.body.resumedSessionId
});
} catch (error) {
logger.warn('Failed to register resumed session with status manager', {
Expand Down
25 changes: 8 additions & 17 deletions src/services/claude-process-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,44 +58,35 @@ export class ClaudeProcessManager extends EventEmitter {
}

/**
* Find the Claude executable from node_modules
* Since @anthropic-ai/claude-code is a dependency, claude should be in node_modules/.bin
* Find the Claude executable
* Priority: node_modules (bundled) > PATH > global install
*/
private findClaudeExecutable(): string {
// First: find claude from node_modules (bundled version for consistency)
// When running as an npm package, find claude relative to this module
// __dirname will be something like /path/to/node_modules/cui-server/dist/services
const packageRoot = path.resolve(__dirname, '..', '..');
const claudePath = path.join(packageRoot, 'node_modules', '.bin', 'claude');

if (existsSync(claudePath)) {
return claudePath;
}

// Try from the parent node_modules (when cui-server is installed as a dependency)
// packageRoot -> /node_modules/cui-server
// parent -> /node_modules, so /node_modules/.bin/claude
const parentModulesPath = path.resolve(packageRoot, '..', '.bin', 'claude');
if (existsSync(parentModulesPath)) {
return parentModulesPath;
}

// Fallback: try from current working directory (for local development)
const cwdPath = path.join(process.cwd(), 'node_modules', '.bin', 'claude');
if (existsSync(cwdPath)) {
return cwdPath;
}

// Final fallback: try to locate on PATH
const pathEnv = process.env.PATH || '';
const pathDirs = pathEnv.split(path.delimiter);
for (const dir of pathDirs) {
const candidate = path.join(dir, 'claude');
if (existsSync(candidate)) {
return candidate;
}
}

throw new Error('Claude executable not found in node_modules. Ensure @anthropic-ai/claude-code is installed.');

throw new Error('Claude executable not found. Ensure @anthropic-ai/claude-code is installed globally or in node_modules.');
}

/**
Expand Down
21 changes: 19 additions & 2 deletions src/web/chat/components/ConversationView/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,25 @@ export function ConversationView() {
permissionMode
});

// Navigate immediately to the new session
navigate(`/c/${response.sessionId}`);
// Add optimistic user message immediately
const messageId = `optimistic-${Date.now()}`;
const optimisticUserMessage: ChatMessage = {
id: messageId,
messageId: messageId,
type: 'user',
content: message,
timestamp: new Date().toISOString(),
workingDirectory: workingDirectory || currentWorkingDirectory,
};
addMessage(optimisticUserMessage);

// Set streamingId immediately to start receiving messages
setStreamingId(response.streamingId);

// Navigate to the new session (may be same sessionId in Claude 2.x)
if (response.sessionId !== sessionId) {
navigate(`/c/${response.sessionId}`);
}
} catch (err: any) {
setError(err.message || 'Failed to send message');
}
Expand Down
23 changes: 14 additions & 9 deletions src/web/chat/contexts/ConversationsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
import { api } from '../services/api';
import { useStreamStatus } from './StreamStatusContext';
import type { ConversationSummary, WorkingDirectory, ConversationSummaryWithLiveStatus } from '../types';
import { isPathUnderHome } from '../utils/path-utils';

interface RecentDirectory {
lastDate: string;
Expand Down Expand Up @@ -59,20 +60,24 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {

const updateRecentDirectories = (convs: ConversationSummary[], apiDirectories?: Record<string, RecentDirectory> | null) => {
const newDirectories: Record<string, RecentDirectory> = {};
// First, add API directories if available

// First, add API directories if available (filter to home paths only)
if (apiDirectories) {
Object.assign(newDirectories, apiDirectories);
Object.entries(apiDirectories).forEach(([path, dir]) => {
if (isPathUnderHome(path)) {
newDirectories[path] = dir;
}
});
}
// Then, process conversations and merge with API data

// Then, process conversations and merge with API data (filter to home paths only)
convs.forEach(conv => {
if (conv.projectPath) {
if (conv.projectPath && isPathUnderHome(conv.projectPath)) {
const pathParts = conv.projectPath.split('/');
const shortname = pathParts[pathParts.length - 1] || conv.projectPath;

// If API didn't provide this directory, or if conversation is more recent
if (!newDirectories[conv.projectPath] ||
if (!newDirectories[conv.projectPath] ||
new Date(conv.updatedAt) > new Date(newDirectories[conv.projectPath].lastDate)) {
newDirectories[conv.projectPath] = {
lastDate: conv.updatedAt,
Expand All @@ -81,7 +86,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
}
}
});

setRecentDirectories(newDirectories);
};

Expand Down
31 changes: 26 additions & 5 deletions src/web/chat/hooks/useStreaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ export function useStreaming(
}, []);

const connect = useCallback(async () => {
// Guard against multiple connections
if (!streamingId || readerRef.current || abortControllerRef.current) {
// Guard against connection without streamingId
if (!streamingId) {
return;
}

// If already connecting/connected, skip
if (abortControllerRef.current) {
return;
}

Expand Down Expand Up @@ -139,10 +144,26 @@ export function useStreaming(
}, [streamingId, disconnect]);

useEffect(() => {
// Clean up previous connection before establishing new one
if (readerRef.current) {
readerRef.current.cancel().catch(() => {});
readerRef.current = null;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsConnected(false);

if (streamingId) {
connect();
} else {
disconnect();
// Small delay to ensure cleanup is complete
const timeoutId = setTimeout(() => {
connect();
}, 50);
return () => {
clearTimeout(timeoutId);
disconnect();
};
}

return () => {
Expand Down
Loading