feat: Add SSH remote execution for multi-host Claude sessions
- Backend: SSH execution via spawn() with -T flag for JSON streaming - Backend: PATH setup for non-login shells on remote hosts - Backend: History loading via SSH (tail -n 2000 for large files) - Frontend: Host selector UI with colored buttons in Sidebar - Frontend: Auto-select first project when host changes - Frontend: Pass host parameter to history and session APIs - Docker: Install openssh-client, mount SSH keys Enables running Claude sessions on remote hosts via SSH while viewing them through the web UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,17 @@
|
|||||||
FROM node:20-slim
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# Install SSH client for remote execution
|
||||||
|
RUN apt-get update && apt-get install -y openssh-client && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create app directory
|
# Create app directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create node user home structure for claude
|
# Create node user home structure for claude and ssh
|
||||||
RUN mkdir -p /home/node/.local/bin && \
|
RUN mkdir -p /home/node/.local/bin && \
|
||||||
mkdir -p /home/node/.local/share/claude && \
|
mkdir -p /home/node/.local/share/claude && \
|
||||||
mkdir -p /home/node/.claude && \
|
mkdir -p /home/node/.claude && \
|
||||||
mkdir -p /home/node/.config/claude && \
|
mkdir -p /home/node/.config/claude && \
|
||||||
|
mkdir -p /home/node/.ssh && \
|
||||||
chown -R node:node /home/node
|
chown -R node:node /home/node
|
||||||
|
|
||||||
# Create symlink for claude binary
|
# Create symlink for claude binary
|
||||||
|
|||||||
@@ -91,12 +91,19 @@ app.get('/api/projects', (req, res) => {
|
|||||||
return res.status(404).json({ error: `Host '${hostId}' not found` });
|
return res.status(404).json({ error: `Host '${hostId}' not found` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only local hosts for now
|
// For SSH hosts, return the basePaths from config (can't scan remote directories)
|
||||||
if (host.connection.type !== 'local') {
|
if (host.connection.type !== 'local') {
|
||||||
|
const projects = host.basePaths.map(basePath => ({
|
||||||
|
path: basePath,
|
||||||
|
name: basename(basePath),
|
||||||
|
type: 'base',
|
||||||
|
isBase: true
|
||||||
|
}));
|
||||||
return res.json({
|
return res.json({
|
||||||
projects: [],
|
projects,
|
||||||
host: hostId,
|
host: hostId,
|
||||||
message: 'SSH hosts not yet supported for project listing'
|
hostInfo: { name: host.name, color: host.color },
|
||||||
|
message: 'SSH host - showing base paths only'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,38 +140,11 @@ app.get('/api/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get session history for a project
|
// Parse history content into messages
|
||||||
app.get('/api/history/:project', (req, res) => {
|
function parseHistoryContent(content) {
|
||||||
try {
|
|
||||||
const projectPath = decodeURIComponent(req.params.project);
|
|
||||||
// Convert project path to Claude's folder naming convention
|
|
||||||
const projectFolder = projectPath.replace(/\//g, '-');
|
|
||||||
const historyDir = `/home/node/.claude/projects/${projectFolder}`;
|
|
||||||
|
|
||||||
if (!existsSync(historyDir)) {
|
|
||||||
return res.json({ messages: [], sessionId: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the most recent non-agent session file
|
|
||||||
const files = readdirSync(historyDir)
|
|
||||||
.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
|
|
||||||
.map(f => ({
|
|
||||||
name: f,
|
|
||||||
path: join(historyDir, f),
|
|
||||||
mtime: statSync(join(historyDir, f)).mtime
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.mtime - a.mtime);
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
return res.json({ messages: [], sessionId: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestFile = files[0];
|
|
||||||
const sessionId = latestFile.name.replace('.jsonl', '');
|
|
||||||
const content = readFileSync(latestFile.path, 'utf-8');
|
|
||||||
const lines = content.split('\n').filter(l => l.trim());
|
const lines = content.split('\n').filter(l => l.trim());
|
||||||
|
|
||||||
const messages = [];
|
const messages = [];
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(line);
|
const event = JSON.parse(line);
|
||||||
@@ -220,6 +200,83 @@ app.get('/api/history/:project', (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session history for a project (supports SSH hosts)
|
||||||
|
app.get('/api/history/:project', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const projectPath = decodeURIComponent(req.params.project);
|
||||||
|
const hostId = req.query.host;
|
||||||
|
const host = hostId ? hostsConfig.hosts[hostId] : null;
|
||||||
|
const isSSH = host?.connection?.type === 'ssh';
|
||||||
|
|
||||||
|
// Convert project path to Claude's folder naming convention
|
||||||
|
const projectFolder = projectPath.replace(/\//g, '-');
|
||||||
|
|
||||||
|
if (isSSH) {
|
||||||
|
// Load history via SSH
|
||||||
|
const { host: sshHost, user, port = 22 } = host.connection;
|
||||||
|
const sshTarget = `${user}@${sshHost}`;
|
||||||
|
const historyDir = `~/.claude/projects/${projectFolder}`;
|
||||||
|
|
||||||
|
// Find latest session file via SSH
|
||||||
|
const findCmd = `ls -t ${historyDir}/*.jsonl 2>/dev/null | grep -v agent- | head -1`;
|
||||||
|
|
||||||
|
const { execSync } = await import('child_process');
|
||||||
|
try {
|
||||||
|
const latestFile = execSync(`ssh -T -o StrictHostKeyChecking=no -p ${port} ${sshTarget} "${findCmd}"`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 10000
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
if (!latestFile) {
|
||||||
|
return res.json({ messages: [], sessionId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the last 2000 lines (to handle large history files)
|
||||||
|
const content = execSync(`ssh -T -o StrictHostKeyChecking=no -p ${port} ${sshTarget} "tail -n 2000 '${latestFile}'"`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 30000,
|
||||||
|
maxBuffer: 50 * 1024 * 1024 // 50MB buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = basename(latestFile).replace('.jsonl', '');
|
||||||
|
const messages = parseHistoryContent(content);
|
||||||
|
|
||||||
|
return res.json({ messages, sessionId, source: 'ssh' });
|
||||||
|
} catch (sshErr) {
|
||||||
|
console.error('SSH history fetch error:', sshErr.message);
|
||||||
|
return res.json({ messages: [], sessionId: null, error: sshErr.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local history
|
||||||
|
const historyDir = `/home/node/.claude/projects/${projectFolder}`;
|
||||||
|
|
||||||
|
if (!existsSync(historyDir)) {
|
||||||
|
return res.json({ messages: [], sessionId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most recent non-agent session file
|
||||||
|
const files = readdirSync(historyDir)
|
||||||
|
.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
|
||||||
|
.map(f => ({
|
||||||
|
name: f,
|
||||||
|
path: join(historyDir, f),
|
||||||
|
mtime: statSync(join(historyDir, f)).mtime
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return res.json({ messages: [], sessionId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestFile = files[0];
|
||||||
|
const sessionId = latestFile.name.replace('.jsonl', '');
|
||||||
|
const content = readFileSync(latestFile.path, 'utf-8');
|
||||||
|
const messages = parseHistoryContent(content);
|
||||||
|
|
||||||
res.json({ messages, sessionId });
|
res.json({ messages, sessionId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error reading history:', err);
|
console.error('Error reading history:', err);
|
||||||
@@ -240,16 +297,21 @@ wss.on('connection', (ws, req) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startClaudeSession = (projectPath, resume = true) => {
|
const startClaudeSession = (projectPath, resume = true, hostId = null) => {
|
||||||
if (claudeProcess) {
|
if (claudeProcess) {
|
||||||
console.log(`[${sessionId}] Killing existing Claude process`);
|
console.log(`[${sessionId}] Killing existing Claude process`);
|
||||||
claudeProcess.kill();
|
claudeProcess.kill();
|
||||||
}
|
}
|
||||||
|
|
||||||
currentProject = projectPath;
|
currentProject = projectPath;
|
||||||
console.log(`[${sessionId}] Starting Claude in: ${projectPath} (resume: ${resume})`);
|
|
||||||
|
|
||||||
const args = [
|
// Get host config
|
||||||
|
const host = hostId ? hostsConfig.hosts[hostId] : null;
|
||||||
|
const isSSH = host?.connection?.type === 'ssh';
|
||||||
|
|
||||||
|
console.log(`[${sessionId}] Starting Claude in: ${projectPath} (resume: ${resume}, host: ${hostId || 'local'}, ssh: ${isSSH})`);
|
||||||
|
|
||||||
|
const claudeArgs = [
|
||||||
'-p',
|
'-p',
|
||||||
'--output-format', 'stream-json',
|
'--output-format', 'stream-json',
|
||||||
'--input-format', 'stream-json',
|
'--input-format', 'stream-json',
|
||||||
@@ -260,14 +322,38 @@ wss.on('connection', (ws, req) => {
|
|||||||
|
|
||||||
// Add continue flag to resume most recent conversation
|
// Add continue flag to resume most recent conversation
|
||||||
if (resume) {
|
if (resume) {
|
||||||
args.push('--continue');
|
claudeArgs.push('--continue');
|
||||||
}
|
}
|
||||||
|
|
||||||
claudeProcess = spawn('claude', args, {
|
if (isSSH) {
|
||||||
|
// SSH execution
|
||||||
|
const { host: sshHost, user, port = 22 } = host.connection;
|
||||||
|
const sshTarget = `${user}@${sshHost}`;
|
||||||
|
|
||||||
|
// Build the remote command with PATH setup for non-login shells
|
||||||
|
const remoteCmd = `export PATH="$HOME/.local/bin:$PATH" && cd ${projectPath} && claude ${claudeArgs.join(' ')}`;
|
||||||
|
|
||||||
|
console.log(`[${sessionId}] SSH to ${sshTarget}:${port} - ${remoteCmd}`);
|
||||||
|
|
||||||
|
claudeProcess = spawn('ssh', [
|
||||||
|
'-T', // Disable TTY (needed for JSON streaming)
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'BatchMode=no',
|
||||||
|
'-p', String(port),
|
||||||
|
sshTarget,
|
||||||
|
remoteCmd
|
||||||
|
], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Local execution
|
||||||
|
claudeProcess = spawn('claude', claudeArgs, {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
cwd: projectPath || '/projects',
|
cwd: projectPath || '/projects',
|
||||||
env: { ...process.env }
|
env: { ...process.env }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
sessions.set(sessionId, { process: claudeProcess, project: projectPath });
|
sessions.set(sessionId, { process: claudeProcess, project: projectPath });
|
||||||
|
|
||||||
@@ -329,7 +415,7 @@ wss.on('connection', (ws, req) => {
|
|||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'start_session':
|
case 'start_session':
|
||||||
startClaudeSession(data.project || '/projects', data.resume !== false);
|
startClaudeSession(data.project || '/projects', data.resume !== false, data.host || null);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'user_message':
|
case 'user_message':
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ services:
|
|||||||
- ./config/.config/claude:/home/node/.config/claude:rw
|
- ./config/.config/claude:/home/node/.config/claude:rw
|
||||||
# Hosts configuration
|
# Hosts configuration
|
||||||
- ./config/hosts.json:/app/config/hosts.json:ro
|
- ./config/hosts.json:/app/config/hosts.json:ro
|
||||||
|
# SSH keys for remote execution
|
||||||
|
- /home/sumdex/.ssh/id_rsa:/home/node/.ssh/id_rsa:ro
|
||||||
|
- /home/sumdex/.ssh/known_hosts:/home/node/.ssh/known_hosts:ro
|
||||||
# Project directories for Claude to work in
|
# Project directories for Claude to work in
|
||||||
- /home/sumdex/projects:/projects:rw
|
- /home/sumdex/projects:/projects:rw
|
||||||
- /home/sumdex/docker:/docker:rw
|
- /home/sumdex/docker:/docker:rw
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ function App() {
|
|||||||
} = useClaudeSession();
|
} = useClaudeSession();
|
||||||
|
|
||||||
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
|
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
|
||||||
|
const [selectedHost, setSelectedHost] = useState('local');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [resumeSession, setResumeSession] = useState(true);
|
const [resumeSession, setResumeSession] = useState(true);
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ function App() {
|
|||||||
}, [setMessages]);
|
}, [setMessages]);
|
||||||
|
|
||||||
const handleStartSession = () => {
|
const handleStartSession = () => {
|
||||||
startSession(selectedProject, resumeSession);
|
startSession(selectedProject, resumeSession, selectedHost);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle slash commands
|
// Handle slash commands
|
||||||
@@ -167,6 +168,8 @@ function App() {
|
|||||||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
onSelectProject={setSelectedProject}
|
onSelectProject={setSelectedProject}
|
||||||
|
selectedHost={selectedHost}
|
||||||
|
onSelectHost={setSelectedHost}
|
||||||
sessionActive={sessionActive}
|
sessionActive={sessionActive}
|
||||||
onStartSession={handleStartSession}
|
onStartSession={handleStartSession}
|
||||||
onStopSession={stopSession}
|
onStopSession={stopSession}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings } from 'lucide-react';
|
import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings, Server } from 'lucide-react';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ export function Sidebar({
|
|||||||
onToggle,
|
onToggle,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
onSelectProject,
|
onSelectProject,
|
||||||
|
selectedHost,
|
||||||
|
onSelectHost,
|
||||||
sessionActive,
|
sessionActive,
|
||||||
onStartSession,
|
onStartSession,
|
||||||
onStopSession,
|
onStopSession,
|
||||||
@@ -15,16 +17,39 @@ export function Sidebar({
|
|||||||
resumeSession,
|
resumeSession,
|
||||||
onToggleResume
|
onToggleResume
|
||||||
}) {
|
}) {
|
||||||
|
const [hosts, setHosts] = useState([]);
|
||||||
const [projects, setProjects] = useState([]);
|
const [projects, setProjects] = useState([]);
|
||||||
const [customPath, setCustomPath] = useState('');
|
const [customPath, setCustomPath] = useState('');
|
||||||
|
|
||||||
|
// Fetch hosts on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_URL}/api/projects`)
|
fetch(`${API_URL}/api/hosts`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => setProjects(data.projects || data))
|
.then(data => {
|
||||||
|
setHosts(data.hosts || []);
|
||||||
|
if (!selectedHost && data.defaultHost) {
|
||||||
|
onSelectHost(data.defaultHost);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch projects when host changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedHost) return;
|
||||||
|
fetch(`${API_URL}/api/projects?host=${selectedHost}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
const projectList = data.projects || data;
|
||||||
|
setProjects(projectList);
|
||||||
|
// Auto-select first project when host changes
|
||||||
|
if (projectList.length > 0) {
|
||||||
|
onSelectProject(projectList[0].path);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, [selectedHost, onSelectProject]);
|
||||||
|
|
||||||
const handleCustomPath = () => {
|
const handleCustomPath = () => {
|
||||||
if (customPath.trim()) {
|
if (customPath.trim()) {
|
||||||
onSelectProject(customPath.trim());
|
onSelectProject(customPath.trim());
|
||||||
@@ -49,6 +74,36 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||||
|
{/* Host Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
||||||
|
Host
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{hosts.map((host) => (
|
||||||
|
<button
|
||||||
|
key={host.id}
|
||||||
|
onClick={() => onSelectHost(host.id)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||||
|
transition-colors border
|
||||||
|
${selectedHost === host.id
|
||||||
|
? 'border-orange-500/50 text-white'
|
||||||
|
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Server className="w-3.5 h-3.5" style={{ color: host.color }} />
|
||||||
|
<span>{host.name}</span>
|
||||||
|
{!host.isLocal && <span className="text-xs text-dark-500">(SSH)</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Project Selection */}
|
{/* Project Selection */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
||||||
|
|||||||
@@ -203,14 +203,15 @@ export function useClaudeSession() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadHistory = useCallback(async (project) => {
|
const loadHistory = useCallback(async (project, host = null) => {
|
||||||
try {
|
try {
|
||||||
const encodedProject = encodeURIComponent(project);
|
const encodedProject = encodeURIComponent(project);
|
||||||
const response = await fetch(`${API_URL}/api/history/${encodedProject}`);
|
const hostParam = host ? `?host=${host}` : '';
|
||||||
|
const response = await fetch(`${API_URL}/api/history/${encodedProject}${hostParam}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.messages && data.messages.length > 0) {
|
if (data.messages && data.messages.length > 0) {
|
||||||
console.log(`Loaded ${data.messages.length} messages from history`);
|
console.log(`Loaded ${data.messages.length} messages from history (source: ${data.source || 'local'})`);
|
||||||
setMessages(data.messages);
|
setMessages(data.messages);
|
||||||
return data.sessionId;
|
return data.sessionId;
|
||||||
}
|
}
|
||||||
@@ -221,7 +222,7 @@ export function useClaudeSession() {
|
|||||||
return null;
|
return null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startSession = useCallback(async (project = '/projects', resume = true) => {
|
const startSession = useCallback(async (project = '/projects', resume = true, host = null) => {
|
||||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||||
setError('Not connected');
|
setError('Not connected');
|
||||||
return;
|
return;
|
||||||
@@ -229,13 +230,14 @@ export function useClaudeSession() {
|
|||||||
|
|
||||||
// Load history before starting session if resuming
|
// Load history before starting session if resuming
|
||||||
if (resume) {
|
if (resume) {
|
||||||
await loadHistory(project);
|
await loadHistory(project, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
type: 'start_session',
|
type: 'start_session',
|
||||||
project,
|
project,
|
||||||
resume
|
resume,
|
||||||
|
host
|
||||||
}));
|
}));
|
||||||
}, [loadHistory]);
|
}, [loadHistory]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user