mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
1009 lines
36 KiB
TypeScript
1009 lines
36 KiB
TypeScript
/**
|
|
* @fileoverview Session management routes.
|
|
* Covers session CRUD, input/output, terminal buffer, quick-start, quick-run,
|
|
* auto-clear, auto-compact, image watcher, flicker filter, and logout.
|
|
*/
|
|
|
|
import { FastifyInstance } from 'fastify';
|
|
import { join, dirname } from 'node:path';
|
|
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
import fs from 'node:fs/promises';
|
|
import {
|
|
ApiErrorCode,
|
|
createErrorResponse,
|
|
getErrorMessage,
|
|
type ApiResponse,
|
|
type QuickStartResponse,
|
|
type SessionColor,
|
|
} from '../../types.js';
|
|
import { Session } from '../../session.js';
|
|
import { SseEvent } from '../sse-events.js';
|
|
import {
|
|
CreateSessionSchema,
|
|
SessionNameSchema,
|
|
SessionColorSchema,
|
|
RunPromptSchema,
|
|
SessionInputWithLimitSchema,
|
|
ResizeSchema,
|
|
AutoClearSchema,
|
|
AutoCompactSchema,
|
|
ImageWatcherSchema,
|
|
FlickerFilterSchema,
|
|
QuickRunSchema,
|
|
QuickStartSchema,
|
|
} from '../schemas.js';
|
|
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, validatePathWithinBase } from '../route-helpers.js';
|
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
|
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
|
import { imageWatcher } from '../../image-watcher.js';
|
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
|
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
|
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
|
import { RunSummaryTracker } from '../../run-summary.js';
|
|
|
|
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
|
|
|
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
|
// eslint-disable-next-line no-control-regex
|
|
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
|
|
// eslint-disable-next-line no-control-regex
|
|
const CTRL_L_PATTERN = /\x0c/g;
|
|
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
|
|
|
export function registerSessionRoutes(
|
|
app: FastifyInstance,
|
|
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
|
): void {
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Auth
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ========== Logout ==========
|
|
|
|
app.post('/api/logout', async (req, reply) => {
|
|
// Invalidate server-side session token (not just the browser cookie)
|
|
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
|
if (sessionToken) {
|
|
ctx.authSessions?.delete(sessionToken);
|
|
}
|
|
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
|
return { success: true };
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Session CRUD (list, create, rename, color, delete, detail)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ========== Session Listing ==========
|
|
|
|
app.get('/api/sessions', async () => {
|
|
return ctx.getLightSessionsState();
|
|
});
|
|
|
|
// ========== Session Creation ==========
|
|
|
|
app.post('/api/sessions', async (req) => {
|
|
// Prevent unbounded session creation
|
|
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
return createErrorResponse(
|
|
ApiErrorCode.OPERATION_FAILED,
|
|
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`
|
|
);
|
|
}
|
|
|
|
const result = CreateSessionSchema.safeParse(req.body);
|
|
if (!result.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
}
|
|
const body = result.data;
|
|
const workingDir = body.workingDir || process.cwd();
|
|
|
|
// Validate workingDir exists and is a directory
|
|
if (body.workingDir) {
|
|
try {
|
|
const stat = statSync(workingDir);
|
|
if (!stat.isDirectory()) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
|
}
|
|
} catch {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
|
}
|
|
}
|
|
|
|
// Write env overrides to .claude/settings.local.json if provided
|
|
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
|
|
await updateCaseEnvVars(workingDir, body.envOverrides);
|
|
}
|
|
|
|
// Check OpenCode availability if requested
|
|
if (body.mode === 'opencode') {
|
|
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
|
if (!isOpenCodeAvailable()) {
|
|
return createErrorResponse(
|
|
ApiErrorCode.OPERATION_FAILED,
|
|
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
|
);
|
|
}
|
|
}
|
|
|
|
const globalNice = await ctx.getGlobalNiceConfig();
|
|
const modelConfig = await ctx.getModelConfig();
|
|
const mode = body.mode || 'claude';
|
|
const model =
|
|
mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined;
|
|
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
|
const session = new Session({
|
|
workingDir,
|
|
mode,
|
|
name: body.name || '',
|
|
mux: ctx.mux,
|
|
useMux: true,
|
|
niceConfig: globalNice,
|
|
model,
|
|
claudeMode: claudeModeConfig.claudeMode,
|
|
allowedTools: claudeModeConfig.allowedTools,
|
|
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
|
resumeSessionId: body.resumeSessionId,
|
|
});
|
|
|
|
ctx.addSession(session);
|
|
ctx.store.incrementSessionsCreated();
|
|
ctx.persistSessionState(session);
|
|
await ctx.setupSessionListeners(session);
|
|
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
|
|
|
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
|
|
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
|
const lightState = ctx.getSessionStateWithRespawn(session);
|
|
ctx.broadcast(SseEvent.SessionCreated, lightState);
|
|
return { success: true, session: lightState };
|
|
});
|
|
|
|
// ========== Rename Session ==========
|
|
|
|
app.put('/api/sessions/:id/name', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const result = SessionNameSchema.safeParse(req.body);
|
|
if (!result.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const body = result.data;
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
|
session.name = name;
|
|
// Also update the mux session name if applicable
|
|
ctx.mux.updateSessionName(id, session.name);
|
|
ctx.persistSessionState(session);
|
|
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
|
return { success: true, name: session.name };
|
|
});
|
|
|
|
// ========== Set Session Color ==========
|
|
|
|
app.put('/api/sessions/:id/color', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const result = SessionColorSchema.safeParse(req.body);
|
|
if (!result.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const body = result.data;
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
|
if (!validColors.includes(body.color)) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid color');
|
|
}
|
|
|
|
session.setColor(body.color as SessionColor);
|
|
ctx.persistSessionState(session);
|
|
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
|
return { success: true, color: session.color };
|
|
});
|
|
|
|
// ========== Delete Session ==========
|
|
|
|
app.delete('/api/sessions/:id', async (req): Promise<ApiResponse> => {
|
|
const { id } = req.params as { id: string };
|
|
const query = req.query as { killMux?: string };
|
|
const killMux = query.killMux !== 'false'; // Default to true
|
|
|
|
if (!ctx.sessions.has(id)) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
await ctx.cleanupSession(id, killMux, 'user_delete');
|
|
return { success: true };
|
|
});
|
|
|
|
// ========== Delete All Sessions ==========
|
|
|
|
app.delete('/api/sessions', async (): Promise<ApiResponse<{ killed: number }>> => {
|
|
const sessionIds = Array.from(ctx.sessions.keys());
|
|
let killed = 0;
|
|
|
|
for (const id of sessionIds) {
|
|
if (ctx.sessions.has(id)) {
|
|
await ctx.cleanupSession(id, true, 'user_bulk_delete');
|
|
killed++;
|
|
}
|
|
}
|
|
|
|
return { success: true, data: { killed } };
|
|
});
|
|
|
|
// ========== Get Session Detail ==========
|
|
|
|
app.get('/api/sessions/:id', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
|
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
|
return ctx.getSessionStateWithRespawn(session);
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Session Data (output, ralph state, run summary, active tools)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ========== Get Session Output ==========
|
|
|
|
app.get('/api/sessions/:id/output', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
textOutput: session.textOutput,
|
|
messages: session.messages,
|
|
errorBuffer: session.errorBuffer,
|
|
},
|
|
};
|
|
});
|
|
|
|
// ========== Get Ralph State ==========
|
|
|
|
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
loop: session.ralphLoopState,
|
|
todos: session.ralphTodos,
|
|
todoStats: session.ralphTodoStats,
|
|
},
|
|
};
|
|
});
|
|
|
|
// ========== Get Run Summary ==========
|
|
|
|
app.get('/api/sessions/:id/run-summary', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
const tracker = ctx.runSummaryTrackers.get(id);
|
|
if (!tracker) {
|
|
// Create a fresh tracker if one doesn't exist (shouldn't happen normally)
|
|
const newTracker = new RunSummaryTracker(id, session.name);
|
|
ctx.runSummaryTrackers.set(id, newTracker);
|
|
return { success: true, summary: newTracker.getSummary() };
|
|
}
|
|
|
|
// Update session name in case it changed
|
|
tracker.setSessionName(session.name);
|
|
|
|
return { success: true, summary: tracker.getSummary() };
|
|
});
|
|
|
|
// ========== Get Active Tools ==========
|
|
|
|
app.get('/api/sessions/:id/active-tools', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
tools: session.activeTools,
|
|
},
|
|
};
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Session Execution (run prompt, interactive mode, shell mode)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ========== Run Prompt ==========
|
|
|
|
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
|
|
const { id } = req.params as { id: string };
|
|
const result = RunPromptSchema.safeParse(req.body);
|
|
if (!result.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
}
|
|
const { prompt } = result.data;
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
if (session.isBusy()) {
|
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
|
}
|
|
|
|
// Run async, don't wait
|
|
session.runPrompt(prompt).catch((err) => {
|
|
ctx.broadcast(SseEvent.SessionError, { id, error: err.message });
|
|
});
|
|
|
|
ctx.broadcast(SseEvent.SessionRunning, { id, prompt });
|
|
return { success: true };
|
|
});
|
|
|
|
// ========== Start Interactive Mode ==========
|
|
|
|
app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
|
|
const { id } = req.params as { id: string };
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
if (session.isBusy()) {
|
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
|
}
|
|
|
|
try {
|
|
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
|
// Ralph tracker is not supported for opencode sessions
|
|
if (
|
|
session.mode !== 'opencode' &&
|
|
ctx.store.getConfig().ralphEnabled &&
|
|
!session.ralphTracker.autoEnableDisabled
|
|
) {
|
|
autoConfigureRalph(session, session.workingDir, ctx);
|
|
if (!session.ralphTracker.enabled) {
|
|
session.ralphTracker.enable();
|
|
}
|
|
}
|
|
|
|
await session.startInteractive();
|
|
getLifecycleLog().log({
|
|
event: 'started',
|
|
sessionId: id,
|
|
name: session.name,
|
|
mode: session.mode,
|
|
});
|
|
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
|
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
|
|
|
return { success: true };
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
// ========== Start Shell Mode ==========
|
|
|
|
app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
|
|
const { id } = req.params as { id: string };
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
if (session.isBusy()) {
|
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
|
}
|
|
|
|
try {
|
|
await session.startShell();
|
|
getLifecycleLog().log({
|
|
event: 'started',
|
|
sessionId: id,
|
|
name: session.name,
|
|
mode: 'shell',
|
|
});
|
|
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
|
|
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
|
return { success: true };
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Terminal I/O (input, resize, buffer)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ========== Send Input ==========
|
|
|
|
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
|
const { id } = req.params as { id: string };
|
|
const result = SessionInputWithLimitSchema.safeParse(req.body);
|
|
if (!result.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
}
|
|
const { input, useMux } = result.data;
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
const inputStr = String(input);
|
|
if (inputStr.length > MAX_INPUT_LENGTH) {
|
|
return createErrorResponse(
|
|
ApiErrorCode.INVALID_INPUT,
|
|
`Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`
|
|
);
|
|
}
|
|
|
|
// Write input to PTY. Direct write is synchronous; writeViaMux
|
|
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
|
if (useMux) {
|
|
// Fire-and-forget: don't block HTTP response on tmux child process.
|
|
// Fallback to direct write on failure.
|
|
session
|
|
.writeViaMux(inputStr)
|
|
.then((ok) => {
|
|
if (!ok) {
|
|
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
|
session.write(inputStr);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
session.write(inputStr);
|
|
});
|
|
} else {
|
|
session.write(inputStr);
|
|
}
|
|
return { success: true };
|
|
});
|
|
|
|
// ========== Resize Terminal ==========
|
|
|
|
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
|
|
const { id } = req.params as { id: string };
|
|
const result = ResizeSchema.safeParse(req.body);
|
|
if (!result.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
}
|
|
const { cols, rows } = result.data;
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
session.resize(cols, rows);
|
|
return { success: true };
|
|
});
|
|
|
|
// ========== Get Terminal Buffer ==========
|
|
|
|
// Query params:
|
|
// tail=<bytes> - Only return last N bytes (faster initial load)
|
|
app.get('/api/sessions/:id/terminal', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const query = req.query as { tail?: string };
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
|
const fullSize = session.terminalBufferLength;
|
|
let truncated = false;
|
|
let cleanBuffer: string;
|
|
|
|
if (tailBytes > 0 && fullSize > tailBytes) {
|
|
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
|
// Banner is near the top and gets discarded by tail anyway.
|
|
cleanBuffer = session.terminalBuffer.slice(-tailBytes);
|
|
truncated = true;
|
|
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
|
|
// and start from there. This prevents xterm.js from parsing a partial escape
|
|
// sequence which corrupts cursor position for all subsequent Ink redraws.
|
|
const firstNewline = cleanBuffer.indexOf('\n');
|
|
if (firstNewline > 0 && firstNewline < 4096) {
|
|
cleanBuffer = cleanBuffer.slice(firstNewline + 1);
|
|
}
|
|
} else {
|
|
// Full buffer: clean junk before actual Claude content
|
|
cleanBuffer = session.terminalBuffer;
|
|
|
|
// Find where Claude banner starts (has color codes before "Claude")
|
|
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
|
|
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
|
|
let lineStart = claudeMatch.index;
|
|
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
|
|
lineStart--;
|
|
}
|
|
cleanBuffer = cleanBuffer.slice(lineStart);
|
|
}
|
|
}
|
|
|
|
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
|
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
|
|
|
return {
|
|
terminalBuffer: cleanBuffer,
|
|
status: session.status,
|
|
fullSize,
|
|
truncated,
|
|
};
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Session Settings (auto-clear, auto-compact, image watcher, flicker filter)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ========== Auto-Clear ==========
|
|
|
|
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const acResult = AutoClearSchema.safeParse(req.body);
|
|
if (!acResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const body = acResult.data;
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
session.setAutoClear(body.enabled, body.threshold);
|
|
ctx.persistSessionState(session);
|
|
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
autoClear: {
|
|
enabled: session.autoClearEnabled,
|
|
threshold: session.autoClearThreshold,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
// ========== Auto-Compact ==========
|
|
|
|
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const compactResult = AutoCompactSchema.safeParse(req.body);
|
|
if (!compactResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const body = compactResult.data;
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
|
ctx.persistSessionState(session);
|
|
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
autoCompact: {
|
|
enabled: session.autoCompactEnabled,
|
|
threshold: session.autoCompactThreshold,
|
|
prompt: session.autoCompactPrompt,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
// ========== Image Watcher ==========
|
|
|
|
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const iwResult = ImageWatcherSchema.safeParse(req.body);
|
|
if (!iwResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const body = iwResult.data;
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
if (body.enabled) {
|
|
imageWatcher.watchSession(session.id, session.workingDir);
|
|
} else {
|
|
imageWatcher.unwatchSession(session.id);
|
|
}
|
|
|
|
// Store state on session for persistence
|
|
session.imageWatcherEnabled = body.enabled;
|
|
ctx.persistSessionState(session);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
imageWatcherEnabled: body.enabled,
|
|
},
|
|
};
|
|
});
|
|
|
|
// ========== Flicker Filter ==========
|
|
|
|
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const ffResult = FlickerFilterSchema.safeParse(req.body);
|
|
if (!ffResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const body = ffResult.data;
|
|
const session = ctx.sessions.get(id);
|
|
|
|
if (!session) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
}
|
|
|
|
session.flickerFilterEnabled = body.enabled;
|
|
ctx.persistSessionState(session);
|
|
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
flickerFilterEnabled: body.enabled,
|
|
},
|
|
};
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Quick Actions (quick-run, quick-start)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ========== Quick Run ==========
|
|
|
|
app.post('/api/run', async (req) => {
|
|
// Prevent unbounded session creation
|
|
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
return createErrorResponse(
|
|
ApiErrorCode.SESSION_BUSY,
|
|
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`
|
|
);
|
|
}
|
|
|
|
const qrResult = QuickRunSchema.safeParse(req.body);
|
|
if (!qrResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const { prompt, workingDir } = qrResult.data;
|
|
|
|
if (!prompt.trim()) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
|
}
|
|
const dir = workingDir || process.cwd();
|
|
|
|
// Validate workingDir exists and is a directory
|
|
if (workingDir) {
|
|
try {
|
|
const stat = statSync(dir);
|
|
if (!stat.isDirectory()) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
|
}
|
|
} catch {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
|
}
|
|
}
|
|
|
|
const session = new Session({ workingDir: dir });
|
|
ctx.addSession(session);
|
|
ctx.store.incrementSessionsCreated();
|
|
ctx.persistSessionState(session);
|
|
await ctx.setupSessionListeners(session);
|
|
getLifecycleLog().log({
|
|
event: 'created',
|
|
sessionId: session.id,
|
|
name: session.name,
|
|
reason: 'run_prompt',
|
|
});
|
|
|
|
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
|
|
|
try {
|
|
const result = await session.runPrompt(prompt);
|
|
// Clean up session after completion to prevent memory leak
|
|
await ctx.cleanupSession(session.id, true, 'run_prompt_complete');
|
|
return { success: true, sessionId: session.id, ...result };
|
|
} catch (err) {
|
|
// Clean up session on error too
|
|
await ctx.cleanupSession(session.id, true, 'run_prompt_error');
|
|
return { success: false, sessionId: session.id, error: getErrorMessage(err) };
|
|
}
|
|
});
|
|
|
|
// ========== Quick Start ==========
|
|
|
|
app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => {
|
|
// Prevent unbounded session creation
|
|
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
return createErrorResponse(
|
|
ApiErrorCode.SESSION_BUSY,
|
|
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
|
);
|
|
}
|
|
|
|
const result = QuickStartSchema.safeParse(req.body);
|
|
if (!result.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
|
}
|
|
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data;
|
|
|
|
// Check OpenCode availability if requested
|
|
if (mode === 'opencode') {
|
|
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
|
if (!isOpenCodeAvailable()) {
|
|
return createErrorResponse(
|
|
ApiErrorCode.OPERATION_FAILED,
|
|
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
|
);
|
|
}
|
|
}
|
|
|
|
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
|
if (!casePath) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
|
}
|
|
|
|
// Create case folder and CLAUDE.md if it doesn't exist
|
|
if (!existsSync(casePath)) {
|
|
try {
|
|
mkdirSync(casePath, { recursive: true });
|
|
mkdirSync(join(casePath, 'src'), { recursive: true });
|
|
|
|
// Read settings to get custom template path
|
|
const templatePath = await ctx.getDefaultClaudeMdPath();
|
|
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
|
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
|
|
|
// Write .claude/settings.local.json with hooks for desktop notifications
|
|
// (Claude-specific — OpenCode uses its own plugin system)
|
|
if (mode !== 'opencode') {
|
|
await writeHooksConfig(casePath);
|
|
}
|
|
|
|
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
|
}
|
|
}
|
|
|
|
// Create a new session with the case as working directory
|
|
// Apply global Nice priority config and model config from settings
|
|
const niceConfig = await ctx.getGlobalNiceConfig();
|
|
const qsModelConfig = await ctx.getModelConfig();
|
|
const qsModel =
|
|
mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined;
|
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
|
const session = new Session({
|
|
workingDir: casePath,
|
|
mux: ctx.mux,
|
|
useMux: true,
|
|
mode: mode,
|
|
niceConfig: niceConfig,
|
|
model: qsModel,
|
|
claudeMode: qsClaudeModeConfig.claudeMode,
|
|
allowedTools: qsClaudeModeConfig.allowedTools,
|
|
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
|
});
|
|
|
|
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
|
// so the initial state already has the phrase configured (only if globally enabled)
|
|
if (mode === 'claude' && ctx.store.getConfig().ralphEnabled) {
|
|
autoConfigureRalph(session, casePath, ctx);
|
|
if (!session.ralphTracker.enabled) {
|
|
session.ralphTracker.enable();
|
|
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
|
}
|
|
}
|
|
|
|
ctx.addSession(session);
|
|
ctx.store.incrementSessionsCreated();
|
|
ctx.persistSessionState(session);
|
|
await ctx.setupSessionListeners(session);
|
|
getLifecycleLog().log({
|
|
event: 'created',
|
|
sessionId: session.id,
|
|
name: session.name,
|
|
reason: 'quick_start',
|
|
});
|
|
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
|
|
|
// Start in the appropriate mode
|
|
try {
|
|
if (mode === 'shell') {
|
|
await session.startShell();
|
|
getLifecycleLog().log({
|
|
event: 'started',
|
|
sessionId: session.id,
|
|
name: session.name,
|
|
mode: 'shell',
|
|
});
|
|
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
|
|
} else {
|
|
// Both 'claude' and 'opencode' modes use startInteractive()
|
|
await session.startInteractive();
|
|
getLifecycleLog().log({
|
|
event: 'started',
|
|
sessionId: session.id,
|
|
name: session.name,
|
|
mode,
|
|
});
|
|
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
|
|
}
|
|
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
|
|
|
// Save lastUsedCase to settings for TUI/web sync
|
|
try {
|
|
const settingsFilePath = SETTINGS_PATH;
|
|
let settings: Record<string, unknown> = {};
|
|
try {
|
|
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
}
|
|
settings.lastUsedCase = caseName;
|
|
const dir = dirname(settingsFilePath);
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
// Use async write to avoid blocking event loop
|
|
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
|
|
// Non-critical but log for debugging
|
|
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
|
|
});
|
|
} catch (err) {
|
|
// Non-critical but log for debugging
|
|
console.warn('[Server] Failed to prepare settings update:', err);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
sessionId: session.id,
|
|
casePath,
|
|
caseName,
|
|
};
|
|
} catch (err) {
|
|
// Clean up session on error to prevent orphaned resources
|
|
await ctx.cleanupSession(session.id, true, 'quick_start_error');
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// History — list past Claude conversations for resume
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
app.get('/api/history/sessions', async () => {
|
|
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
|
const results: Array<{
|
|
sessionId: string;
|
|
workingDir: string;
|
|
projectKey: string;
|
|
sizeBytes: number;
|
|
lastModified: string;
|
|
}> = [];
|
|
|
|
try {
|
|
const projectDirs = await fs.readdir(projectsDir);
|
|
for (const projDir of projectDirs) {
|
|
const projPath = join(projectsDir, projDir);
|
|
const stat = await fs.stat(projPath).catch(() => null);
|
|
if (!stat?.isDirectory()) continue;
|
|
|
|
// Decode project key to working dir. The encoding replaces '/' with '-',
|
|
// which is lossy when path components contain '-'. Do naive decode first,
|
|
// then verify it exists. Fall back to HOME if the decoded path is invalid.
|
|
const naiveDecode = projDir.replace(/^-/, '/').replace(/-/g, '/');
|
|
const dirExists = await fs
|
|
.access(naiveDecode)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
const workingDir = dirExists ? naiveDecode : process.env.HOME || '/tmp';
|
|
|
|
const entries = await fs.readdir(projPath);
|
|
for (const entry of entries) {
|
|
if (!entry.endsWith('.jsonl')) continue;
|
|
const sessionId = entry.replace('.jsonl', '');
|
|
// Only valid UUIDs
|
|
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
|
|
|
|
const filePath = join(projPath, entry);
|
|
const fileStat = await fs.stat(filePath).catch(() => null);
|
|
if (!fileStat) continue;
|
|
// Skip files too small to contain real conversation (metadata-only sessions
|
|
// like file-history-snapshot entries are typically < 4KB)
|
|
if (fileStat.size < 4000) continue;
|
|
|
|
// Quick content check: verify actual conversation data exists.
|
|
// Sessions with only file-history-snapshot or hook_progress entries have
|
|
// no "user"/"assistant" messages and will fail claude --resume.
|
|
// Files > 50KB are almost certainly real conversations (skip the read).
|
|
if (fileStat.size < 50000) {
|
|
try {
|
|
const fd = await fs.open(filePath, 'r');
|
|
const buf = Buffer.alloc(16384);
|
|
const { bytesRead } = await fd.read(buf, 0, 16384, 0);
|
|
await fd.close();
|
|
const head = buf.toString('utf8', 0, bytesRead);
|
|
if (
|
|
!head.includes('"type":"user"') &&
|
|
!head.includes('"type":"assistant"') &&
|
|
!head.includes('"type":"summary"')
|
|
) {
|
|
continue; // No conversation content — skip
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
results.push({
|
|
sessionId,
|
|
workingDir,
|
|
projectKey: projDir,
|
|
sizeBytes: fileStat.size,
|
|
lastModified: fileStat.mtime.toISOString(),
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
// Projects dir may not exist
|
|
}
|
|
|
|
// Sort by lastModified descending
|
|
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
|
|
return { sessions: results.slice(0, 50) };
|
|
});
|
|
}
|