feat: Major UI improvements and SSH-only mode
- Tool rendering: Unified tool_use/tool_result cards with collapsible results - Special rendering for WebSearch, WebFetch, Task, Write tools - File upload support with drag & drop - Permission dialog for tool approvals - Status bar with session stats and permission mode toggle - SSH-only mode: Removed local container execution - Host switching disabled during active session with visual indicator - Directory browser: Browse remote directories via SSH - Recent directories dropdown with localStorage persistence - Follow-up messages during generation - Improved scroll behavior with "back to bottom" button 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2",
|
||||
"cors": "^2.8.5",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^9.0.0",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { createServer } from 'http';
|
||||
import { spawn } from 'child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import cors from 'cors';
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { join, basename, extname } from 'path';
|
||||
import multer from 'multer';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
@@ -13,6 +14,68 @@ app.use(express.json());
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const DEBUG = process.env.DEBUG === 'true';
|
||||
// Upload to /projects/.claude-uploads so Claude can access them via mounted volume
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/projects/.claude-uploads';
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// Allowed file types
|
||||
const ALLOWED_TYPES = {
|
||||
// Images
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
// Text/Code
|
||||
'text/plain': '.txt',
|
||||
'text/markdown': '.md',
|
||||
'text/csv': '.csv',
|
||||
'text/html': '.html',
|
||||
'text/css': '.css',
|
||||
'text/javascript': '.js',
|
||||
'application/json': '.json',
|
||||
'application/xml': '.xml',
|
||||
'text/xml': '.xml',
|
||||
'application/x-yaml': '.yaml',
|
||||
'text/yaml': '.yaml'
|
||||
};
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const sessionId = req.params.sessionId || 'default';
|
||||
const sessionDir = join(UPLOAD_DIR, sessionId);
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
cb(null, sessionDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Preserve original filename with timestamp prefix to avoid collisions
|
||||
const timestamp = Date.now();
|
||||
const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
cb(null, `${timestamp}-${safeName}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (ALLOWED_TYPES[file.mimetype]) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
// Also allow common code file extensions
|
||||
const ext = extname(file.originalname).toLowerCase();
|
||||
const codeExtensions = ['.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.sh', '.bash', '.zsh', '.yml', '.yaml', '.toml', '.ini', '.conf', '.md', '.txt', '.json', '.xml', '.html', '.css', '.scss', '.less', '.sql', '.rb', '.php', '.swift', '.kt', '.scala', '.r', '.lua', '.pl', '.pm'];
|
||||
if (codeExtensions.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`File type not allowed: ${file.mimetype} (${ext})`));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load hosts configuration
|
||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json';
|
||||
@@ -35,6 +98,12 @@ loadConfig();
|
||||
// Store active Claude sessions
|
||||
const sessions = new Map();
|
||||
|
||||
// Control request counter for unique IDs
|
||||
let controlRequestCounter = 0;
|
||||
function generateRequestId() {
|
||||
return `req_${++controlRequestCounter}_${Date.now().toString(16)}`;
|
||||
}
|
||||
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
@@ -140,6 +209,144 @@ app.get('/api/health', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Browse directories on a host (for directory picker)
|
||||
app.get('/api/browse', async (req, res) => {
|
||||
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
|
||||
const path = req.query.path || '~';
|
||||
const host = hostsConfig.hosts[hostId];
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: `Host '${hostId}' not found` });
|
||||
}
|
||||
|
||||
// For SSH hosts, execute ls command remotely
|
||||
if (host.connection.type === 'ssh') {
|
||||
const { host: sshHost, user, port = 22 } = host.connection;
|
||||
// Expand ~ to home directory, list only directories, format as JSON-friendly output
|
||||
const lsCmd = `cd ${path} 2>/dev/null && pwd && ls -1F 2>/dev/null | grep '/$' | sed 's/\\/$//'`;
|
||||
const sshCmd = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port} ${user}@${sshHost} "${lsCmd}"`;
|
||||
|
||||
try {
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const { stdout, stderr } = await execAsync(sshCmd, { timeout: 10000 });
|
||||
const lines = stdout.trim().split('\n').filter(Boolean);
|
||||
const currentPath = lines[0] || path;
|
||||
const directories = lines.slice(1).map(name => ({
|
||||
name,
|
||||
path: currentPath === '/' ? `/${name}` : `${currentPath}/${name}`,
|
||||
type: 'directory'
|
||||
}));
|
||||
|
||||
// Add parent directory if not at root
|
||||
if (currentPath !== '/') {
|
||||
const parentPath = currentPath.split('/').slice(0, -1).join('/') || '/';
|
||||
directories.unshift({
|
||||
name: '..',
|
||||
path: parentPath,
|
||||
type: 'parent'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
currentPath,
|
||||
directories,
|
||||
host: hostId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Browse error:', err.message);
|
||||
res.status(500).json({ error: `Failed to browse: ${err.message}` });
|
||||
}
|
||||
} else {
|
||||
// Local browsing
|
||||
try {
|
||||
const resolvedPath = path === '~' ? process.env.HOME || '/home' : path;
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return res.status(404).json({ error: `Path not found: ${resolvedPath}` });
|
||||
}
|
||||
|
||||
const entries = readdirSync(resolvedPath);
|
||||
const directories = entries
|
||||
.filter(name => {
|
||||
try {
|
||||
return statSync(join(resolvedPath, name)).isDirectory() && !name.startsWith('.');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map(name => ({
|
||||
name,
|
||||
path: join(resolvedPath, name),
|
||||
type: 'directory'
|
||||
}));
|
||||
|
||||
// Add parent directory
|
||||
if (resolvedPath !== '/') {
|
||||
const parentPath = join(resolvedPath, '..');
|
||||
directories.unshift({
|
||||
name: '..',
|
||||
path: parentPath,
|
||||
type: 'parent'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
currentPath: resolvedPath,
|
||||
directories,
|
||||
host: hostId
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: `Failed to browse: ${err.message}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// File upload endpoint
|
||||
app.post('/api/upload/:sessionId', upload.array('files', 5), (req, res) => {
|
||||
try {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
const uploadedFiles = req.files.map(file => {
|
||||
// Convert container path to host path for Claude
|
||||
// /projects/.claude-uploads/... -> /home/sumdex/projects/.claude-uploads/...
|
||||
const hostPath = file.path.replace('/projects/', '/home/sumdex/projects/');
|
||||
return {
|
||||
originalName: file.originalname,
|
||||
savedName: file.filename,
|
||||
path: hostPath, // Use host path so Claude can read it
|
||||
containerPath: file.path, // Keep container path for reference
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
isImage: file.mimetype.startsWith('image/')
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`[Upload] Session ${req.params.sessionId}: ${uploadedFiles.length} files uploaded`);
|
||||
res.json({ files: uploadedFiles });
|
||||
} catch (err) {
|
||||
console.error('[Upload] Error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Error handler for multer
|
||||
app.use((err, req, res, next) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB` });
|
||||
}
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
if (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Parse history content into messages
|
||||
function parseHistoryContent(content) {
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
@@ -290,10 +497,54 @@ wss.on('connection', (ws, req) => {
|
||||
|
||||
let claudeProcess = null;
|
||||
let currentProject = null;
|
||||
let currentHostId = null; // Track current host for restart
|
||||
let isInitialized = false;
|
||||
let currentPermissionMode = 'default';
|
||||
let savedPermissionMode = 'default'; // Store mode before plan mode switch
|
||||
let inPlanMode = false; // Track if we're in plan mode (to require approval for ExitPlanMode)
|
||||
const pendingControlRequests = new Map();
|
||||
const pendingPermissionRequests = new Map(); // Track tool permission requests
|
||||
|
||||
// Cleanup stale pending requests (TTL: 30 seconds)
|
||||
const REQUEST_TTL = 30000;
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, req] of pendingControlRequests) {
|
||||
if (req.createdAt && now - req.createdAt > REQUEST_TTL) {
|
||||
pendingControlRequests.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id, req] of pendingPermissionRequests) {
|
||||
if (req.createdAt && now - req.createdAt > REQUEST_TTL) {
|
||||
pendingPermissionRequests.delete(id);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// Helper to set permission mode via control protocol (needs claudeProcess)
|
||||
const setPermissionModeViaControl = (mode) => {
|
||||
if (!claudeProcess) return;
|
||||
const modeRequestId = generateRequestId();
|
||||
const modeRequest = {
|
||||
type: 'control_request',
|
||||
request_id: modeRequestId,
|
||||
request: {
|
||||
subtype: 'set_permission_mode',
|
||||
mode: mode
|
||||
}
|
||||
};
|
||||
console.log(`[${sessionId}] Auto-switching permission mode to: ${mode}`);
|
||||
claudeProcess.stdin.write(JSON.stringify(modeRequest) + '\n');
|
||||
pendingControlRequests.set(modeRequestId, { type: 'set_permission_mode', mode, createdAt: Date.now() });
|
||||
};
|
||||
|
||||
const sendToClient = (type, data) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() }));
|
||||
try {
|
||||
ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() }));
|
||||
} catch (err) {
|
||||
console.error(`[${sessionId}] WebSocket send failed:`, err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -304,6 +555,7 @@ wss.on('connection', (ws, req) => {
|
||||
}
|
||||
|
||||
currentProject = projectPath;
|
||||
currentHostId = hostId; // Save for potential restart
|
||||
|
||||
// Get host config
|
||||
const host = hostId ? hostsConfig.hosts[hostId] : null;
|
||||
@@ -316,8 +568,8 @@ wss.on('connection', (ws, req) => {
|
||||
'--output-format', 'stream-json',
|
||||
'--input-format', 'stream-json',
|
||||
'--include-partial-messages',
|
||||
'--verbose',
|
||||
'--dangerously-skip-permissions'
|
||||
'--verbose'
|
||||
// Note: No --dangerously-skip-permissions - we handle permissions via control protocol
|
||||
];
|
||||
|
||||
// Add continue flag to resume most recent conversation
|
||||
@@ -362,26 +614,133 @@ wss.on('connection', (ws, req) => {
|
||||
project: projectPath
|
||||
});
|
||||
|
||||
// Send control protocol initialization after process starts
|
||||
const initializeControlProtocol = () => {
|
||||
const requestId = generateRequestId();
|
||||
const initRequest = {
|
||||
type: 'control_request',
|
||||
request_id: requestId,
|
||||
request: {
|
||||
subtype: 'initialize',
|
||||
hooks: null
|
||||
}
|
||||
};
|
||||
console.log(`[${sessionId}] Sending control initialization`);
|
||||
claudeProcess.stdin.write(JSON.stringify(initRequest) + '\n');
|
||||
pendingControlRequests.set(requestId, { type: 'initialize', createdAt: Date.now() });
|
||||
};
|
||||
|
||||
// Small delay to ensure process is ready
|
||||
setTimeout(initializeControlProtocol, 100);
|
||||
|
||||
// Handle stdout (JSON events)
|
||||
let buffer = '';
|
||||
let pendingLine = '';
|
||||
claudeProcess.stdout.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
console.log(`[${sessionId}] stdout chunk:`, chunk.substring(0, 200));
|
||||
if (DEBUG) console.log(`[${sessionId}] stdout chunk:`, chunk.substring(0, 200));
|
||||
|
||||
buffer += chunk;
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
const parts = (pendingLine + chunk).split('\n');
|
||||
pendingLine = parts.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
for (const line of parts) {
|
||||
if (line.trim()) {
|
||||
console.log(`[${sessionId}] Processing line:`, line.substring(0, 100));
|
||||
if (DEBUG) console.log(`[${sessionId}] Processing line:`, line.substring(0, 100));
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
console.log(`[${sessionId}] Event type:`, event.type);
|
||||
if (DEBUG) console.log(`[${sessionId}] Event type:`, event.type);
|
||||
|
||||
// Handle control protocol requests (permission prompts from CLI)
|
||||
if (event.type === 'control_request') {
|
||||
const request = event.request;
|
||||
const requestId = event.request_id;
|
||||
|
||||
if (request?.subtype === 'can_use_tool') {
|
||||
if (DEBUG) console.log(`[${sessionId}] Permission request for tool: ${request.tool_name}`);
|
||||
|
||||
// Special handling for ExitPlanMode - always send to UI for approval
|
||||
const isPlanApproval = request.tool_name === 'ExitPlanMode';
|
||||
|
||||
// Track plan mode state for ExitPlanMode
|
||||
if (isPlanApproval && !inPlanMode) {
|
||||
savedPermissionMode = currentPermissionMode;
|
||||
inPlanMode = true;
|
||||
if (DEBUG) console.log(`[${sessionId}] Entering plan mode, saved mode: ${savedPermissionMode}`);
|
||||
}
|
||||
|
||||
// Store the request so we know which tool is being approved/denied
|
||||
pendingPermissionRequests.set(requestId, {
|
||||
toolName: request.tool_name,
|
||||
toolInput: request.input,
|
||||
isPlanApproval,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
// Send to frontend for user approval
|
||||
sendToClient('permission_request', {
|
||||
requestId,
|
||||
toolName: request.tool_name,
|
||||
toolInput: request.input,
|
||||
permissionSuggestions: request.permission_suggestions,
|
||||
blockedPath: request.blocked_path,
|
||||
isPlanApproval // Flag for special UI treatment
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle control protocol responses
|
||||
if (event.type === 'control_response') {
|
||||
const response = event.response;
|
||||
const requestId = response?.request_id;
|
||||
const pending = pendingControlRequests.get(requestId);
|
||||
|
||||
if (pending) {
|
||||
pendingControlRequests.delete(requestId);
|
||||
|
||||
if (response.subtype === 'success') {
|
||||
if (pending.type === 'initialize') {
|
||||
isInitialized = true;
|
||||
console.log(`[${sessionId}] Control protocol initialized`);
|
||||
// Send available commands/models to frontend
|
||||
sendToClient('control_initialized', {
|
||||
commands: response.response?.commands,
|
||||
models: response.response?.models,
|
||||
account: response.response?.account
|
||||
});
|
||||
} else if (pending.type === 'set_permission_mode') {
|
||||
currentPermissionMode = pending.mode;
|
||||
console.log(`[${sessionId}] Permission mode changed to: ${currentPermissionMode}`);
|
||||
sendToClient('permission_mode_changed', {
|
||||
mode: currentPermissionMode
|
||||
});
|
||||
}
|
||||
} else if (response.subtype === 'error') {
|
||||
console.error(`[${sessionId}] Control request error:`, response.error);
|
||||
sendToClient('control_error', {
|
||||
requestId,
|
||||
error: response.error
|
||||
});
|
||||
}
|
||||
}
|
||||
// Don't forward control responses as regular events
|
||||
continue;
|
||||
}
|
||||
|
||||
// Debug: log all tool_use blocks in assistant messages
|
||||
if (event.type === 'assistant' && event.message?.content) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === 'tool_use') {
|
||||
if (DEBUG) console.log(`[${sessionId}] Tool use detected: ${block.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: ExitPlanMode is now handled exclusively via Control Protocol (can_use_tool)
|
||||
// to avoid tool_result/control_response confusion and message compaction issues
|
||||
|
||||
sendToClient('claude_event', { event });
|
||||
} catch (e) {
|
||||
// Non-JSON output, send as raw
|
||||
console.log(`[${sessionId}] Raw output:`, line.substring(0, 100));
|
||||
if (DEBUG) console.log(`[${sessionId}] Raw output:`, line.substring(0, 100));
|
||||
sendToClient('raw_output', { content: line });
|
||||
}
|
||||
}
|
||||
@@ -391,7 +750,7 @@ wss.on('connection', (ws, req) => {
|
||||
// Handle stderr
|
||||
claudeProcess.stderr.on('data', (data) => {
|
||||
const content = data.toString();
|
||||
console.log(`[${sessionId}] stderr:`, content);
|
||||
if (DEBUG) console.log(`[${sessionId}] stderr:`, content);
|
||||
sendToClient('stderr', { content });
|
||||
});
|
||||
|
||||
@@ -411,7 +770,7 @@ wss.on('connection', (ws, req) => {
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
console.log(`[${sessionId}] Received:`, data.type);
|
||||
if (DEBUG) console.log(`[${sessionId}] Received:`, data.type);
|
||||
|
||||
switch (data.type) {
|
||||
case 'start_session':
|
||||
@@ -432,7 +791,7 @@ wss.on('connection', (ws, req) => {
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`[${sessionId}] Sending to Claude:`, data.message.substring(0, 50) + '...');
|
||||
if (DEBUG) console.log(`[${sessionId}] Sending to Claude:`, data.message.substring(0, 50) + '...');
|
||||
claudeProcess.stdin.write(JSON.stringify(payload) + '\n');
|
||||
break;
|
||||
|
||||
@@ -443,6 +802,139 @@ wss.on('connection', (ws, req) => {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stop_generation':
|
||||
// Kill the process and restart with --continue to resume session
|
||||
if (claudeProcess) {
|
||||
console.log(`[${sessionId}] Stop generation: killing process and restarting`);
|
||||
|
||||
// Save current state for restart
|
||||
const restartProject = currentProject;
|
||||
const restartHost = currentHostId;
|
||||
const restartPermissionMode = currentPermissionMode;
|
||||
|
||||
// Kill the process
|
||||
claudeProcess.kill('SIGKILL');
|
||||
claudeProcess = null;
|
||||
isInitialized = false;
|
||||
|
||||
// Notify frontend
|
||||
sendToClient('generation_stopped', {
|
||||
message: 'Generation stopped, reconnecting...',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Restart after a short delay
|
||||
setTimeout(() => {
|
||||
console.log(`[${sessionId}] Restarting session with --continue`);
|
||||
startClaudeSession(restartProject, true, restartHost);
|
||||
|
||||
// Restore permission mode after initialization
|
||||
savedPermissionMode = restartPermissionMode;
|
||||
}, 500);
|
||||
} else {
|
||||
sendToClient('generation_stopped', {
|
||||
message: 'No active process',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'set_permission_mode':
|
||||
if (!claudeProcess) {
|
||||
sendToClient('error', { message: 'No active Claude session' });
|
||||
return;
|
||||
}
|
||||
if (!isInitialized) {
|
||||
sendToClient('error', { message: 'Control protocol not yet initialized' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow mode change while in plan mode - plan mode takes priority
|
||||
if (inPlanMode) {
|
||||
console.log(`[${sessionId}] Ignoring set_permission_mode while in plan mode (saving ${data.mode} for later)`);
|
||||
// Save what the user wanted so we can restore it after plan approval
|
||||
savedPermissionMode = data.mode;
|
||||
// Notify frontend that we're still in plan mode
|
||||
sendToClient('permission_mode', { mode: 'plan', reason: 'plan_mode_active' });
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = data.mode;
|
||||
const validModes = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
|
||||
if (!validModes.includes(mode)) {
|
||||
sendToClient('error', { message: `Invalid permission mode: ${mode}. Valid: ${validModes.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const modeRequestId = generateRequestId();
|
||||
const modeRequest = {
|
||||
type: 'control_request',
|
||||
request_id: modeRequestId,
|
||||
request: {
|
||||
subtype: 'set_permission_mode',
|
||||
mode: mode
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`[${sessionId}] Setting permission mode to: ${mode}`);
|
||||
claudeProcess.stdin.write(JSON.stringify(modeRequest) + '\n');
|
||||
pendingControlRequests.set(modeRequestId, { type: 'set_permission_mode', mode, createdAt: Date.now() });
|
||||
break;
|
||||
|
||||
case 'get_permission_mode':
|
||||
sendToClient('permission_mode', { mode: currentPermissionMode });
|
||||
break;
|
||||
|
||||
case 'permission_response':
|
||||
if (!claudeProcess) {
|
||||
sendToClient('error', { message: 'No active Claude session' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingPerm = pendingPermissionRequests.get(data.requestId);
|
||||
if (pendingPerm) {
|
||||
pendingPermissionRequests.delete(data.requestId);
|
||||
}
|
||||
|
||||
// All permission responses (including ExitPlanMode/plan approval) use control_response
|
||||
// This avoids tool_result issues when message history is compacted
|
||||
const permResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: data.requestId,
|
||||
response: data.allow
|
||||
? { behavior: 'allow', updated_input: data.updatedInput || null }
|
||||
: { behavior: 'deny', message: data.message || 'User denied permission' }
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`[${sessionId}] Sending permission response: ${data.allow ? 'allow' : 'deny'} for ${data.requestId}`);
|
||||
claudeProcess.stdin.write(JSON.stringify(permResponse) + '\n');
|
||||
|
||||
// Handle plan mode state updates for ExitPlanMode
|
||||
if (pendingPerm?.isPlanApproval) {
|
||||
if (data.allow) {
|
||||
const modeToRestore = savedPermissionMode || 'default';
|
||||
console.log(`[${sessionId}] ExitPlanMode approved - restoring mode to '${modeToRestore}'`);
|
||||
inPlanMode = false;
|
||||
savedPermissionMode = null;
|
||||
|
||||
// Restore previous permission mode after a small delay
|
||||
setTimeout(() => {
|
||||
setPermissionModeViaControl(modeToRestore);
|
||||
}, 100);
|
||||
|
||||
sendToClient('plan_mode_exited', { approved: true });
|
||||
} else {
|
||||
console.log(`[${sessionId}] ExitPlanMode rejected`);
|
||||
sendToClient('plan_mode_exited', { approved: false });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
// Note: plan_approval case removed - now handled via permission_response with isPlanApproval flag
|
||||
|
||||
default:
|
||||
console.log(`[${sessionId}] Unknown message type:`, data.type);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user