/**
* Shared session management utilities
* Used by both MCP server and Web GUI
*/
import { Effect } from 'effect'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
// Get Claude sessions directory
export const getSessionsDir = (): string => path.join(os.homedir(), '.claude', 'projects')
// Get Claude todos directory
export const getTodosDir = (): string => path.join(os.homedir(), '.claude', 'todos')
// Find agent files linked to a session
export const findLinkedAgents = (projectName: string, sessionId: string) =>
Effect.gen(function* () {
const projectPath = path.join(getSessionsDir(), projectName)
const files = yield* Effect.tryPromise(() => fs.readdir(projectPath))
const agentFiles = files.filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl'))
const linkedAgents: string[] = []
for (const agentFile of agentFiles) {
const filePath = path.join(projectPath, agentFile)
const content = yield* Effect.tryPromise(() => fs.readFile(filePath, 'utf-8'))
const firstLine = content.split('\n')[0]
if (firstLine) {
try {
const parsed = JSON.parse(firstLine) as { sessionId?: string }
if (parsed.sessionId === sessionId) {
linkedAgents.push(agentFile.replace('.jsonl', ''))
}
} catch {
// Skip invalid JSON
}
}
}
return linkedAgents
})
// Find orphan agent files (agents whose parent session no longer exists)
export const findOrphanAgents = (projectName: string) =>
Effect.gen(function* () {
const projectPath = path.join(getSessionsDir(), projectName)
const files = yield* Effect.tryPromise(() => fs.readdir(projectPath))
const sessionIds = new Set(
files
.filter((f) => !f.startsWith('agent-') && f.endsWith('.jsonl'))
.map((f) => f.replace('.jsonl', ''))
)
const agentFiles = files.filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl'))
const orphanAgents: Array<{ agentId: string; sessionId: string }> = []
for (const agentFile of agentFiles) {
const filePath = path.join(projectPath, agentFile)
const content = yield* Effect.tryPromise(() => fs.readFile(filePath, 'utf-8'))
const firstLine = content.split('\n')[0]
if (firstLine) {
try {
const parsed = JSON.parse(firstLine) as { sessionId?: string }
if (parsed.sessionId && !sessionIds.has(parsed.sessionId)) {
orphanAgents.push({
agentId: agentFile.replace('.jsonl', ''),
sessionId: parsed.sessionId,
})
}
} catch {
// Skip invalid JSON
}
}
}
return orphanAgents
})
// Delete orphan agent files (move to .bak)
export const deleteOrphanAgents = (projectName: string) =>
Effect.gen(function* () {
const projectPath = path.join(getSessionsDir(), projectName)
const orphans = yield* findOrphanAgents(projectName)
// Create backup directory
const backupDir = path.join(projectPath, '.bak')
yield* Effect.tryPromise(() => fs.mkdir(backupDir, { recursive: true }))
const deletedAgents: string[] = []
for (const orphan of orphans) {
const agentPath = path.join(projectPath, `${orphan.agentId}.jsonl`)
const agentBackupPath = path.join(backupDir, `${orphan.agentId}.jsonl`)
yield* Effect.tryPromise(() => fs.rename(agentPath, agentBackupPath))
deletedAgents.push(orphan.agentId)
}
return { success: true, deletedAgents, count: deletedAgents.length }
})
// Todo item interface
export interface TodoItem {
content: string
status: 'pending' | 'in_progress' | 'completed'
activeForm?: string
}
export interface SessionTodos {
sessionId: string
sessionTodos: TodoItem[]
agentTodos: { agentId: string; todos: TodoItem[] }[]
hasTodos: boolean
}
// Find linked todo files for a session and its agents
export const findLinkedTodos = (sessionId: string, agentIds: string[]) =>
Effect.gen(function* () {
const todosDir = getTodosDir()
// Check if todos directory exists
const exists = yield* Effect.tryPromise(() =>
fs
.access(todosDir)
.then(() => true)
.catch(() => false)
)
if (!exists) {
return {
sessionId,
sessionTodos: [],
agentTodos: [],
hasTodos: false,
} satisfies SessionTodos
}
// Read session's own todo file
const sessionTodoPath = path.join(todosDir, `${sessionId}.json`)
let sessionTodos: TodoItem[] = []
const sessionTodoExists = yield* Effect.tryPromise(() =>
fs
.access(sessionTodoPath)
.then(() => true)
.catch(() => false)
)
if (sessionTodoExists) {
const content = yield* Effect.tryPromise(() => fs.readFile(sessionTodoPath, 'utf-8'))
try {
sessionTodos = JSON.parse(content) as TodoItem[]
} catch {
// Invalid JSON, treat as empty
}
}
// Read agent todo files
const agentTodos: { agentId: string; todos: TodoItem[] }[] = []
for (const agentId of agentIds) {
// Agent todo files are named: {sessionId}-{agentId}.json
const shortAgentId = agentId.replace('agent-', '')
const agentTodoPath = path.join(todosDir, `${sessionId}-agent-${shortAgentId}.json`)
const agentTodoExists = yield* Effect.tryPromise(() =>
fs
.access(agentTodoPath)
.then(() => true)
.catch(() => false)
)
if (agentTodoExists) {
const content = yield* Effect.tryPromise(() => fs.readFile(agentTodoPath, 'utf-8'))
try {
const todos = JSON.parse(content) as TodoItem[]
agentTodos.push({ agentId, todos })
} catch {
// Invalid JSON, skip
}
}
}
const hasTodos = sessionTodos.length > 0 || agentTodos.some((at) => at.todos.length > 0)
return {
sessionId,
sessionTodos,
agentTodos,
hasTodos,
} satisfies SessionTodos
})
// Check if session has any todos (quick check)
export const sessionHasTodos = (sessionId: string, agentIds: string[]) =>
Effect.gen(function* () {
const todosDir = getTodosDir()
// Check if todos directory exists
const exists = yield* Effect.tryPromise(() =>
fs
.access(todosDir)
.then(() => true)
.catch(() => false)
)
if (!exists) return false
// Check session's own todo file
const sessionTodoPath = path.join(todosDir, `${sessionId}.json`)
const sessionTodoExists = yield* Effect.tryPromise(() =>
fs
.access(sessionTodoPath)
.then(() => true)
.catch(() => false)
)
if (sessionTodoExists) {
const content = yield* Effect.tryPromise(() => fs.readFile(sessionTodoPath, 'utf-8'))
try {
const todos = JSON.parse(content) as TodoItem[]
if (todos.length > 0) return true
} catch {
// Invalid JSON, continue
}
}
// Check agent todo files
for (const agentId of agentIds) {
const shortAgentId = agentId.replace('agent-', '')
const agentTodoPath = path.join(todosDir, `${sessionId}-agent-${shortAgentId}.json`)
const agentTodoExists = yield* Effect.tryPromise(() =>
fs
.access(agentTodoPath)
.then(() => true)
.catch(() => false)
)
if (agentTodoExists) {
const content = yield* Effect.tryPromise(() => fs.readFile(agentTodoPath, 'utf-8'))
try {
const todos = JSON.parse(content) as TodoItem[]
if (todos.length > 0) return true
} catch {
// Invalid JSON, continue
}
}
}
return false
})
// Delete linked todo files for a session (move to .bak)
export const deleteLinkedTodos = (sessionId: string, agentIds: string[]) =>
Effect.gen(function* () {
const todosDir = getTodosDir()
// Check if todos directory exists
const exists = yield* Effect.tryPromise(() =>
fs
.access(todosDir)
.then(() => true)
.catch(() => false)
)
if (!exists) return { deletedCount: 0 }
// Create backup directory
const backupDir = path.join(todosDir, '.bak')
yield* Effect.tryPromise(() => fs.mkdir(backupDir, { recursive: true }))
let deletedCount = 0
// Delete session's own todo file
const sessionTodoPath = path.join(todosDir, `${sessionId}.json`)
const sessionTodoExists = yield* Effect.tryPromise(() =>
fs
.access(sessionTodoPath)
.then(() => true)
.catch(() => false)
)
if (sessionTodoExists) {
const backupPath = path.join(backupDir, `${sessionId}.json`)
yield* Effect.tryPromise(() => fs.rename(sessionTodoPath, backupPath))
deletedCount++
}
// Delete agent todo files
for (const agentId of agentIds) {
const shortAgentId = agentId.replace('agent-', '')
const agentTodoPath = path.join(todosDir, `${sessionId}-agent-${shortAgentId}.json`)
const agentTodoExists = yield* Effect.tryPromise(() =>
fs
.access(agentTodoPath)
.then(() => true)
.catch(() => false)
)
if (agentTodoExists) {
const backupPath = path.join(backupDir, `${sessionId}-agent-${shortAgentId}.json`)
yield* Effect.tryPromise(() => fs.rename(agentTodoPath, backupPath))
deletedCount++
}
}
return { deletedCount }
})
// Find all orphan todo files (session no longer exists)
export const findOrphanTodos = () =>
Effect.gen(function* () {
const todosDir = getTodosDir()
const sessionsDir = getSessionsDir()
// Check if directories exist
const [todosExists, sessionsExists] = yield* Effect.all([
Effect.tryPromise(() =>
fs
.access(todosDir)
.then(() => true)
.catch(() => false)
),
Effect.tryPromise(() =>
fs
.access(sessionsDir)
.then(() => true)
.catch(() => false)
),
])
if (!todosExists || !sessionsExists) return []
// Get all todo files
const todoFiles = yield* Effect.tryPromise(() => fs.readdir(todosDir))
const jsonFiles = todoFiles.filter((f) => f.endsWith('.json'))
// Build set of all valid session IDs across all projects
const validSessionIds = new Set<string>()
const projectEntries = yield* Effect.tryPromise(() =>
fs.readdir(sessionsDir, { withFileTypes: true })
)
for (const entry of projectEntries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const projectPath = path.join(sessionsDir, entry.name)
const files = yield* Effect.tryPromise(() => fs.readdir(projectPath))
for (const f of files) {
if (f.endsWith('.jsonl') && !f.startsWith('agent-')) {
validSessionIds.add(f.replace('.jsonl', ''))
}
}
}
// Find orphan todo files
const orphans: string[] = []
for (const todoFile of jsonFiles) {
// Parse session ID from todo filename
// Format: {sessionId}.json or {sessionId}-agent-{agentId}.json
const match = todoFile.match(/^([a-f0-9-]+)(?:-agent-[a-f0-9]+)?\.json$/)
if (match) {
const sessionId = match[1]
if (!validSessionIds.has(sessionId)) {
orphans.push(todoFile)
}
}
}
return orphans
})
// Delete orphan todo files
export const deleteOrphanTodos = () =>
Effect.gen(function* () {
const todosDir = getTodosDir()
const orphans = yield* findOrphanTodos()
if (orphans.length === 0) return { success: true, deletedCount: 0 }
// Create backup directory
const backupDir = path.join(todosDir, '.bak')
yield* Effect.tryPromise(() => fs.mkdir(backupDir, { recursive: true }))
let deletedCount = 0
for (const orphan of orphans) {
const filePath = path.join(todosDir, orphan)
const backupPath = path.join(backupDir, orphan)
yield* Effect.tryPromise(() => fs.rename(filePath, backupPath))
deletedCount++
}
return { success: true, deletedCount }
})