claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
517 lines • 23.8 kB
JavaScript
/**
* Session MCP Tools for CLI
*
* Tool definitions for session management with file persistence.
*/
import { existsSync, readFileSync, readdirSync, unlinkSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { getProjectCwd } from './types.js';
import { mkdirRestricted, readFileMaybeEncrypted, writeFileRestricted, } from '../fs-secure.js';
import { validateIdentifier, validateText } from './validate-input.js';
// Storage paths
const STORAGE_DIR = '.claude-flow';
const SESSION_DIR = 'sessions';
function getSessionDir() {
return join(getProjectCwd(), STORAGE_DIR, SESSION_DIR);
}
function getSessionPath(sessionId) {
// Sanitize sessionId to prevent path traversal
const safeId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
return join(getSessionDir(), `${safeId}.json`);
}
function ensureSessionDir() {
const dir = getSessionDir();
if (!existsSync(dir)) {
mkdirRestricted(dir);
}
}
function loadSession(sessionId) {
try {
const path = getSessionPath(sessionId);
if (existsSync(path)) {
// ADR-096 Phase 2: readFileMaybeEncrypted transparently handles both
// legacy plaintext sessions and post-migration encrypted ones via the
// RFE1 magic-byte sniff.
const data = readFileMaybeEncrypted(path, 'utf-8');
return JSON.parse(data);
}
}
catch {
// Return null on error
}
return null;
}
function saveSession(session) {
ensureSessionDir();
// audit_1776853149979: session JSON contains memory snapshots and agent
// prompts — restrict to owner read/write.
// ADR-096 Phase 2: opt-in encrypt-at-rest. The encrypt flag is honored
// only when CLAUDE_FLOW_ENCRYPT_AT_REST is set; otherwise the legacy
// plaintext path runs unchanged.
writeFileRestricted(getSessionPath(session.sessionId), JSON.stringify(session, null, 2), { encrypt: true });
}
function listSessions() {
ensureSessionDir();
const dir = getSessionDir();
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
const sessions = [];
for (const file of files) {
try {
// ADR-096 Phase 2: same magic-byte sniff for the listing path so a
// mixed plaintext+encrypted dir still enumerates cleanly.
const data = readFileMaybeEncrypted(join(dir, file), 'utf-8');
sessions.push(JSON.parse(data));
}
catch {
// Skip invalid files
}
}
return sessions;
}
// Load related stores for session data
function loadRelatedStores(options) {
const data = {};
if (options.includeMemory) {
try {
const memoryPath = join(getProjectCwd(), STORAGE_DIR, 'memory', 'store.json');
if (existsSync(memoryPath)) {
data.memory = JSON.parse(readFileSync(memoryPath, 'utf-8'));
}
}
catch { /* ignore */ }
}
if (options.includeTasks) {
try {
const taskPath = join(getProjectCwd(), STORAGE_DIR, 'tasks', 'store.json');
if (existsSync(taskPath)) {
data.tasks = JSON.parse(readFileSync(taskPath, 'utf-8'));
}
}
catch { /* ignore */ }
}
if (options.includeAgents) {
try {
const agentPath = join(getProjectCwd(), STORAGE_DIR, 'agents', 'store.json');
if (existsSync(agentPath)) {
data.agents = JSON.parse(readFileSync(agentPath, 'utf-8'));
}
}
catch { /* ignore */ }
}
return data;
}
export const sessionTools = [
{
name: 'session_save',
description: 'Save current session state Use when native conversation memory is wrong because you need durable cross-session state — restoring agent definitions, swarm topology, memory store, breaker history. For in-session continuation only, no tool needed. Pair with session_save before exiting and session_restore on resume.',
category: 'session',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Session name' },
description: { type: 'string', description: 'Session description' },
includeMemory: { type: 'boolean', description: 'Include memory in session' },
includeTasks: { type: 'boolean', description: 'Include tasks in session' },
includeAgents: { type: 'boolean', description: 'Include agents in session' },
},
required: ['name'],
},
handler: async (input) => {
// Validate user-provided input (#1425)
const vName = validateText(input.name, 'name', 256);
if (!vName.valid)
return { success: false, error: vName.error };
if (input.description) {
const v = validateText(input.description, 'description');
if (!v.valid)
return { success: false, error: v.error };
}
const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// Load related data based on options
const data = loadRelatedStores({
includeMemory: input.includeMemory,
includeTasks: input.includeTasks,
includeAgents: input.includeAgents,
});
// Calculate stats
const stats = {
tasks: data.tasks ? Object.keys(data.tasks.tasks || {}).length : 0,
agents: data.agents ? Object.keys(data.agents.agents || {}).length : 0,
memoryEntries: data.memory ? Object.keys(data.memory.entries || {}).length : 0,
totalSize: 0,
};
const session = {
sessionId,
name: input.name,
description: input.description,
savedAt: new Date().toISOString(),
stats,
data: Object.keys(data).length > 0 ? data : undefined,
};
// Calculate size
const sessionJson = JSON.stringify(session);
session.stats.totalSize = Buffer.byteLength(sessionJson, 'utf-8');
saveSession(session);
return {
sessionId,
name: session.name,
savedAt: session.savedAt,
stats: session.stats,
path: getSessionPath(sessionId),
};
},
},
{
name: 'session_restore',
description: 'Restore a saved session Use when native conversation memory is wrong because you need durable cross-session state — restoring agent definitions, swarm topology, memory store, breaker history. For in-session continuation only, no tool needed. Pair with session_save before exiting and session_restore on resume.',
category: 'session',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Session ID to restore' },
name: { type: 'string', description: 'Session name to restore' },
},
},
handler: async (input) => {
// Validate user-provided input (#1425)
if (input.sessionId) {
const v = validateIdentifier(input.sessionId, 'sessionId');
if (!v.valid)
return { success: false, error: v.error };
}
if (input.name) {
const v = validateText(input.name, 'name', 256);
if (!v.valid)
return { success: false, error: v.error };
}
let session = null;
// Try to find by sessionId first
if (input.sessionId) {
session = loadSession(input.sessionId);
}
// Try to find by name if sessionId not found
if (!session && input.name) {
const sessions = listSessions();
session = sessions.find(s => s.name === input.name) || null;
}
// Try to find latest if no params
if (!session && !input.sessionId && !input.name) {
const sessions = listSessions();
if (sessions.length > 0) {
sessions.sort((a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime());
session = sessions[0];
}
}
if (session) {
// Restore data to respective stores (legacy JSON for backward compat).
// audit_1776853149979: tighten perms on the restored stores too.
if (session.data?.memory) {
const memoryDir = join(getProjectCwd(), STORAGE_DIR, 'memory');
if (!existsSync(memoryDir))
mkdirRestricted(memoryDir);
writeFileRestricted(join(memoryDir, 'store.json'), JSON.stringify(session.data.memory, null, 2));
// Also populate active sql.js SQLite database so memory-tools can find entries
try {
const { storeEntry } = await import('../memory/memory-initializer.js');
const memoryData = session.data.memory;
if (memoryData.entries) {
for (const entry of Object.values(memoryData.entries)) {
const key = entry.key || entry.id || '';
const value = entry.value || entry.content || '';
if (key && value) {
await storeEntry({
key,
value,
namespace: entry.namespace || 'restored',
upsert: true,
});
}
}
}
}
catch {
// Legacy JSON restore is the fallback -- sql.js import may not be available
}
}
if (session.data?.tasks) {
const taskDir = join(getProjectCwd(), STORAGE_DIR, 'tasks');
if (!existsSync(taskDir))
mkdirRestricted(taskDir);
writeFileRestricted(join(taskDir, 'store.json'), JSON.stringify(session.data.tasks, null, 2));
}
if (session.data?.agents) {
const agentDir = join(getProjectCwd(), STORAGE_DIR, 'agents');
if (!existsSync(agentDir))
mkdirRestricted(agentDir);
writeFileRestricted(join(agentDir, 'store.json'), JSON.stringify(session.data.agents, null, 2));
}
return {
sessionId: session.sessionId,
name: session.name,
restored: true,
restoredAt: new Date().toISOString(),
stats: session.stats,
};
}
return {
sessionId: input.sessionId || input.name || 'latest',
restored: false,
error: 'Session not found',
};
},
},
{
name: 'session_list',
description: 'List saved sessions Use when native conversation memory is wrong because you need durable cross-session state — restoring agent definitions, swarm topology, memory store, breaker history. For in-session continuation only, no tool needed. Pair with session_save before exiting and session_restore on resume.',
category: 'session',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Maximum sessions to return' },
sortBy: { type: 'string', description: 'Sort field (date, name, size)' },
},
},
handler: async (input) => {
const raw = listSessions();
let sessions = raw.map((s) => ({
...s,
sessionId: s.sessionId || s.id || 'unknown',
savedAt: s.savedAt || s.startedAt || '',
}));
// Sort
const sortBy = input.sortBy || 'date';
if (sortBy === 'date') {
sessions.sort((a, b) => new Date(String(b.savedAt || '')).getTime() - new Date(String(a.savedAt || '')).getTime());
}
else if (sortBy === 'name') {
sessions.sort((a, b) => String(a.name || a.sessionId || '').localeCompare(String(b.name || b.sessionId || '')));
}
else if (sortBy === 'size') {
sessions.sort((a, b) => (b.stats?.totalSize ?? 0) - (a.stats?.totalSize ?? 0));
}
// Apply limit
const limit = input.limit || 10;
sessions = sessions.slice(0, limit);
return {
sessions: sessions.map(s => {
// Project to a stable shape; pull through either source's metadata.
const projection = {
sessionId: s.sessionId,
name: s.name ?? s.sessionId,
description: s.description,
savedAt: s.savedAt,
stats: s.stats ?? null,
};
// Preserve auto-session shape fields when present
if (s.platform)
projection.platform = s.platform;
if (s.metrics)
projection.metrics = s.metrics;
return projection;
}),
total: sessions.length,
limit,
};
},
},
{
name: 'session_delete',
description: 'Delete a saved session Use when native conversation memory is wrong because you need durable cross-session state — restoring agent definitions, swarm topology, memory store, breaker history. For in-session continuation only, no tool needed. Pair with session_save before exiting and session_restore on resume.',
category: 'session',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Session ID to delete' },
},
required: ['sessionId'],
},
handler: async (input) => {
// Validate user-provided input (#1425)
const vId = validateIdentifier(input.sessionId, 'sessionId');
if (!vId.valid)
return { success: false, error: vId.error };
const sessionId = input.sessionId;
const path = getSessionPath(sessionId);
if (existsSync(path)) {
unlinkSync(path);
return {
sessionId,
deleted: true,
deletedAt: new Date().toISOString(),
};
}
return {
sessionId,
deleted: false,
error: 'Session not found',
};
},
},
{
name: 'session_info',
description: 'Get detailed session information Use when native conversation memory is wrong because you need durable cross-session state — restoring agent definitions, swarm topology, memory store, breaker history. For in-session continuation only, no tool needed. Pair with session_save before exiting and session_restore on resume.',
category: 'session',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Session ID' },
},
required: ['sessionId'],
},
handler: async (input) => {
// Validate user-provided input (#1425)
const vId = validateIdentifier(input.sessionId, 'sessionId');
if (!vId.valid)
return { success: false, error: vId.error };
const sessionId = input.sessionId;
const session = loadSession(sessionId);
if (session) {
const path = getSessionPath(sessionId);
const stat = statSync(path);
return {
sessionId: session.sessionId,
name: session.name,
description: session.description,
savedAt: session.savedAt,
stats: session.stats,
fileSize: stat.size,
path,
hasData: {
memory: !!session.data?.memory,
tasks: !!session.data?.tasks,
agents: !!session.data?.agents,
},
};
}
return {
sessionId,
error: 'Session not found',
};
},
},
{
// #1916: `ruflo session current` referenced an unregistered
// `session_current` tool. Returns the most-recently-saved session.
name: 'session_current',
description: 'Return the most-recently-saved session (id, name, stats) — the de-facto "current" one. Use when native conversation memory is wrong because you need to know which durable session is active before exporting/restoring it. For in-session continuation only, no tool needed. Pair with session_export / session_restore.',
category: 'session',
inputSchema: { type: 'object', properties: {} },
handler: async () => {
const dir = getSessionDir();
if (!existsSync(dir))
return { sessionId: '', status: 'none', startedAt: '', error: 'No saved sessions' };
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
if (files.length === 0)
return { sessionId: '', status: 'none', startedAt: '', error: 'No saved sessions' };
let newest = files[0];
let newestMtime = 0;
for (const f of files) {
const mt = statSync(join(dir, f)).mtimeMs;
if (mt >= newestMtime) {
newestMtime = mt;
newest = f;
}
}
const sessionId = newest.replace(/\.json$/, '');
const session = loadSession(sessionId);
if (!session)
return { sessionId, status: 'unknown', startedAt: '', error: 'Session file unreadable' };
return {
sessionId: session.sessionId,
name: session.name,
status: 'active',
startedAt: session.savedAt,
stats: session.stats,
};
},
},
{
// #1916: `ruflo session export <id> -o <file>` referenced an unregistered
// `session_export` tool. Writes the session JSON to a file (if given) and
// returns the session payload.
name: 'session_export',
description: 'Export a saved session (agents, tasks, memory snapshot) to a JSON file and/or return the payload. Use when native Write is wrong because the data is the structured session record (not a freeform file) and you want it serialized consistently for transfer/backup. For writing arbitrary content, native Write is fine. Pair with session_import on the other end.',
category: 'session',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Session ID to export' },
outputPath: { type: 'string', description: 'File path to write the export to (optional)' },
includeMemory: { type: 'boolean', description: 'Include the memory snapshot (advisory — already in the saved record)' },
},
required: ['sessionId'],
},
handler: async (input) => {
const vId = validateIdentifier(input.sessionId, 'sessionId');
if (!vId.valid)
return { success: false, error: vId.error };
const sessionId = input.sessionId;
const session = loadSession(sessionId);
if (!session)
return { sessionId, error: 'Session not found' };
let path = null;
const outputPath = input.outputPath ? String(input.outputPath) : null;
if (outputPath) {
try {
writeFileSync(outputPath, JSON.stringify(session, null, 2), 'utf-8');
path = outputPath;
}
catch (e) {
return { sessionId, error: `Could not write ${outputPath}: ${e.message}` };
}
}
return { sessionId, name: session.name, data: session, path, exportedAt: new Date().toISOString() };
},
},
{
// #1916: `ruflo session import <file>` referenced an unregistered
// `session_import` tool. Reads a session JSON and re-saves it locally.
name: 'session_import',
description: 'Import a session JSON file (produced by session_export) into the local session store and optionally activate it. Use when native Read is wrong because the file is a structured session record that must be re-registered (new id, stats recomputed) rather than just read. For reading the file, native Read is fine. Pair with session_export on the source.',
category: 'session',
inputSchema: {
type: 'object',
properties: {
inputPath: { type: 'string', description: 'Path to the session JSON file to import' },
name: { type: 'string', description: 'Override the imported session name' },
activate: { type: 'boolean', description: 'Make the imported session the current one (advisory)' },
},
required: ['inputPath'],
},
handler: async (input) => {
const inputPath = String(input.inputPath ?? '');
if (!inputPath || !existsSync(inputPath))
return { error: `File not found: ${inputPath || '(empty)'}` };
let parsed;
try {
parsed = JSON.parse(readFileSync(inputPath, 'utf-8'));
}
catch (e) {
return { error: `Invalid session JSON: ${e.message}` };
}
const newId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const stats = parsed.stats || { tasks: 0, agents: 0, memoryEntries: 0, totalSize: 0 };
const session = {
sessionId: newId,
name: input.name ? String(input.name) : (parsed.name || 'imported-session'),
description: parsed.description,
savedAt: new Date().toISOString(),
stats,
data: parsed.data,
};
saveSession(session);
return {
sessionId: newId,
name: session.name,
importedAt: session.savedAt,
stats: {
agentsImported: stats.agents,
tasksImported: stats.tasks,
memoryEntriesImported: stats.memoryEntries,
},
activated: input.activate === true,
};
},
},
];
//# sourceMappingURL=session-tools.js.map