chore: version packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-28 01:35:37 +01:00
co-authored by Claude Opus 4.6
parent 1ab1dd100a
commit 9d9f4d323c
13 changed files with 240 additions and 41 deletions
+1 -7
View File
@@ -398,13 +398,7 @@ export class FileStreamManager extends EventEmitter {
// Check if the resolved path is within the working directory
// or common log directories (/tmp intentionally excluded — world-writable)
const allowedPaths = [
normalizedWorkingDir,
'/var/log',
resolve(homedir(), '.local/share'),
resolve(homedir(), '.cache'),
resolve(homedir(), 'logs'),
];
const allowedPaths = [normalizedWorkingDir, '/var/log', resolve(homedir(), 'logs')];
const isAllowed = allowedPaths.some((allowed) => {
const rel = relative(allowed, absolutePath);
+2 -1
View File
@@ -27,9 +27,10 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
// Falls back to empty object if stdin is unavailable or malformed.
const curlCmd = (event: HookEventType) =>
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
`-H 'Content-Type: application/json' ` +
`-d "{\\"event\\":\\"${event}\\",\\"sessionId\\":\\"$CODEMAN_SESSION_ID\\",\\"data\\":$HOOK_DATA}" ` +
`--data @- ` +
`2>/dev/null || true`;
return {
+8 -8
View File
@@ -444,8 +444,6 @@ export class PlanOrchestrator {
try {
const { result: response } = await session.runPrompt(prompt, { model: this.researchModel });
this.runningSessions.delete(session);
const durationMs = Date.now() - startTime;
// Extract JSON from response
@@ -528,7 +526,6 @@ export class PlanOrchestrator {
return result;
} catch (err) {
this.runningSessions.delete(session);
const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err);
onSubagent?.({
@@ -554,7 +551,10 @@ export class PlanOrchestrator {
durationMs,
};
} finally {
// Always clear the progress interval to prevent memory leaks
// Always clean up session and progress interval — centralizing here
// prevents the race where cancel() and catch both try to manage the set
await session.stop().catch(() => {});
this.runningSessions.delete(session);
clearInterval(progressInterval);
}
}
@@ -617,8 +617,6 @@ export class PlanOrchestrator {
try {
const { result: response } = await session.runPrompt(prompt, { model: this.plannerModel });
this.runningSessions.delete(session);
const durationMs = Date.now() - startTime;
// Extract JSON from response
@@ -670,7 +668,6 @@ export class PlanOrchestrator {
return { success: true, items, gaps, warnings };
} catch (err) {
this.runningSessions.delete(session);
const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err);
onSubagent?.({
@@ -684,7 +681,10 @@ export class PlanOrchestrator {
});
return { success: false, error };
} finally {
// Always clear the progress interval to prevent memory leaks
// Always clean up session and progress interval — centralizing here
// prevents the race where cancel() and catch both try to manage the set
await session.stop().catch(() => {});
this.runningSessions.delete(session);
clearInterval(progressInterval);
}
}
+27 -3
View File
@@ -77,6 +77,7 @@ export class RalphLoop extends EventEmitter {
completion: (sessionId: string, phrase: string) => void;
error: (sessionId: string, error: string) => void;
stopped: (sessionId: string) => void;
taskError: (sessionId: string, taskId: string, error: string) => void;
} | null = null;
constructor(options: RalphLoopOptions = {}) {
@@ -118,11 +119,15 @@ export class RalphLoop extends EventEmitter {
stopped: (sessionId: string) => {
this.handleSessionStopped(sessionId);
},
taskError: (sessionId: string, taskId: string, error: string) => {
this.handleSessionTaskError(sessionId, taskId, error);
},
};
this.sessionManager.on('sessionCompletion', this.sessionEventHandlers.completion);
this.sessionManager.on('sessionError', this.sessionEventHandlers.error);
this.sessionManager.on('sessionStopped', this.sessionEventHandlers.stopped);
this.sessionManager.on('sessionTaskError', this.sessionEventHandlers.taskError);
}
/** Remove event listeners to prevent memory leaks */
@@ -131,6 +136,7 @@ export class RalphLoop extends EventEmitter {
this.sessionManager.off('sessionCompletion', this.sessionEventHandlers.completion);
this.sessionManager.off('sessionError', this.sessionEventHandlers.error);
this.sessionManager.off('sessionStopped', this.sessionEventHandlers.stopped);
this.sessionManager.off('sessionTaskError', this.sessionEventHandlers.taskError);
this.sessionEventHandlers = null;
}
}
@@ -281,8 +287,11 @@ export class RalphLoop extends EventEmitter {
private async tick(): Promise<void> {
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
// Run independent checks in parallel for better performance
await Promise.all([this.checkTimeouts(), this.assignTasks()]);
// Run sequentially: timeouts first so timed-out tasks are cleaned up
// before assignTasks() picks new work (prevents race where both
// mutate the same task concurrently)
await this.checkTimeouts();
await this.assignTasks();
// Check if we should auto-generate tasks (depends on assignment results)
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
@@ -304,7 +313,11 @@ export class RalphLoop extends EventEmitter {
break;
}
await this.assignTaskToSession(task, session);
try {
await this.assignTaskToSession(task, session);
} catch (err) {
console.error(`[RalphLoop] Failed to assign task ${task.id} to session ${session.id}:`, err);
}
}
}
@@ -400,6 +413,17 @@ export class RalphLoop extends EventEmitter {
}
}
private handleSessionTaskError(_sessionId: string, taskId: string, error: string): void {
const task = this.taskQueue.getTask(taskId);
if (!task) {
return;
}
task.fail(error);
this.taskQueue.updateTask(task);
this.emit('taskFailed', task.id, error);
}
private shouldGenerateTasks(): boolean {
// Generate tasks if:
// 1. No pending tasks
+6
View File
@@ -54,6 +54,7 @@ interface SessionHandlers {
error: (data: string) => void;
completion: (phrase: string) => void;
exit: () => void;
taskError: (taskId: string, error: string) => void;
}
export class SessionManager extends EventEmitter {
@@ -134,12 +135,16 @@ export class SessionManager extends EventEmitter {
this.emit('sessionStopped', session.id);
this.updateSessionState(session);
},
taskError: (taskId: string, error: string) => {
this.emit('sessionTaskError', session.id, taskId, error);
},
};
session.on('output', handlers.output);
session.on('error', handlers.error);
session.on('completion', handlers.completion);
session.on('exit', handlers.exit);
session.on('taskError', handlers.taskError);
// Store handlers for later cleanup
this.sessionHandlers.set(session.id, handlers);
@@ -183,6 +188,7 @@ export class SessionManager extends EventEmitter {
session.off('error', handlers.error);
session.off('completion', handlers.completion);
session.off('exit', handlers.exit);
session.off('taskError', handlers.taskError);
this.sessionHandlers.delete(id);
}
+10
View File
@@ -2203,6 +2203,16 @@ export class Session extends EventEmitter {
this._lastActivityAt = Date.now();
this.runPrompt(input).catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
// Clean up task state so the task queue doesn't get stuck
if (this._currentTaskId) {
const taskId = this._currentTaskId;
this._currentTaskId = null;
this._status = 'idle';
this._lastActivityAt = Date.now();
this.emit('taskError', taskId, errorMsg);
} else {
this._status = 'idle';
}
this.emit('error', errorMsg);
});
}
+6 -2
View File
@@ -210,6 +210,10 @@ export class StateStore {
return;
}
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
// The state snapshot is already captured in `json` above.
this.dirty = false;
// Step 2: Create backup via file copy (async, no read+parse+write)
try {
await access(this.filePath);
@@ -223,8 +227,6 @@ export class StateStore {
await writeFile(tempPath, json, 'utf-8');
await rename(tempPath, this.filePath);
// Success! Clear dirty flag AFTER write completes
this.dirty = false;
this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
@@ -232,6 +234,8 @@ export class StateStore {
}
} catch (err) {
console.error('[StateStore] Failed to write state file:', err);
// Re-mark dirty so the data is retried on the next save cycle
this.dirty = true;
this.consecutiveSaveFailures++;
// Try to clean up temp file on error
+1 -1
View File
@@ -6391,7 +6391,7 @@ class CodemanApp {
const displayName = c.name.length > maxNameLength
? c.name.substring(0, maxNameLength) + '…'
: c.name;
options += `<option value="${c.name}">${displayName}</option>`;
options += `<option value="${this.escapeHtml(c.name)}">${this.escapeHtml(displayName)}</option>`;
});
// Add testcase option if it doesn't exist (will be created on first run)
+105 -9
View File
@@ -16,7 +16,17 @@ import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, chmodSync } from 'node:fs';
import {
existsSync,
statSync,
mkdirSync,
writeFileSync,
readdirSync,
readFileSync,
rmSync,
chmodSync,
realpathSync,
} from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { randomBytes, timingSafeEqual } from 'node:crypto';
@@ -1391,15 +1401,21 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
}
// Validate path is within working directory (security: proper path traversal check)
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
const fullPath = resolve(session.workingDir, filePath);
const relativePath = relative(session.workingDir, fullPath);
let resolvedPath: string;
try {
resolvedPath = realpathSync(fullPath);
} catch {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
}
const relativePath = relative(session.workingDir, resolvedPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
}
try {
const stat = await fs.stat(fullPath);
const stat = await fs.stat(resolvedPath);
// Check if it's a binary/media file
const ext = filePath.split('.').pop()?.toLowerCase() || '';
@@ -1460,7 +1476,7 @@ export class WebServer extends EventEmitter {
// Read text file with line limit (bounded to prevent DoS)
const MAX_LINES_LIMIT = 10000;
const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
const content = await fs.readFile(fullPath, 'utf-8');
const content = await fs.readFile(resolvedPath, 'utf-8');
const allLines = content.split('\n');
const truncatedContent = allLines.length > maxLines;
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
@@ -1497,9 +1513,16 @@ export class WebServer extends EventEmitter {
return;
}
// Validate path is within working directory (security: proper path traversal check)
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
const fullPath = resolve(session.workingDir, filePath);
const relativePath = relative(session.workingDir, fullPath);
let resolvedPath: string;
try {
resolvedPath = realpathSync(fullPath);
} catch {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const relativePath = relative(session.workingDir, resolvedPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'));
return;
@@ -1508,7 +1531,7 @@ export class WebServer extends EventEmitter {
try {
// Validate file size before reading (DoS protection - prevent memory exhaustion)
const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files
const stat = await fs.stat(fullPath);
const stat = await fs.stat(resolvedPath);
if (stat.size > MAX_RAW_FILE_SIZE) {
reply
.code(400)
@@ -1541,7 +1564,7 @@ export class WebServer extends EventEmitter {
json: 'application/json',
};
const content = await fs.readFile(fullPath);
const content = await fs.readFile(resolvedPath);
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.send(content);
} catch (err) {
@@ -5027,6 +5050,79 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
} catch (err) {
console.error(`[Server] Error cleaning up respawn controller for ${session.id}:`, err);
}
// Clean up per-session resources that are stale after PTY exit.
// These are only cleaned by cleanupSession() on explicit delete,
// so without this they leak when a session exits without deletion.
try {
// Transcript watcher is tied to the specific PTY run
this.stopTranscriptWatcher(session.id);
// Finalize run summary tracker
const summaryTracker = this.runSummaryTrackers.get(session.id);
if (summaryTracker) {
summaryTracker.recordSessionStopped();
summaryTracker.stop();
this.runSummaryTrackers.delete(session.id);
}
// Flush/clear terminal batching state (no more output coming)
this.terminalBatches.delete(session.id);
this.terminalBatchSizes.delete(session.id);
const batchTimer = this.terminalBatchTimers.get(session.id);
if (batchTimer) {
clearTimeout(batchTimer);
this.terminalBatchTimers.delete(session.id);
}
this.taskUpdateBatches.delete(session.id);
this.stateUpdatePending.delete(session.id);
this.lastTerminalEventTime.delete(session.id);
// Clear pending persist-debounce timer
const pendingPersist = this.persistDebounceTimers.get(session.id);
if (pendingPersist) {
clearTimeout(pendingPersist);
this.persistDebounceTimers.delete(session.id);
}
// Close any active file streams
fileStreamManager.closeSessionStreams(session.id);
// Remove stored listener refs to break closure references (prevents memory leak).
// Without this, the closures capture the Session object (including up to 2MB terminal buffer)
// and keep it alive even after the PTY exits.
const listenerRefs = this.sessionListenerRefs.get(session.id);
if (listenerRefs) {
session.off('terminal', listenerRefs.terminal);
session.off('clearTerminal', listenerRefs.clearTerminal);
session.off('needsRefresh', listenerRefs.needsRefresh);
session.off('message', listenerRefs.message);
session.off('error', listenerRefs.error);
session.off('completion', listenerRefs.completion);
session.off('exit', listenerRefs.exit);
session.off('working', listenerRefs.working);
session.off('idle', listenerRefs.idle);
session.off('taskCreated', listenerRefs.taskCreated);
session.off('taskUpdated', listenerRefs.taskUpdated);
session.off('taskCompleted', listenerRefs.taskCompleted);
session.off('taskFailed', listenerRefs.taskFailed);
session.off('autoClear', listenerRefs.autoClear);
session.off('autoCompact', listenerRefs.autoCompact);
session.off('cliInfoUpdated', listenerRefs.cliInfoUpdated);
session.off('ralphLoopUpdate', listenerRefs.ralphLoopUpdate);
session.off('ralphTodoUpdate', listenerRefs.ralphTodoUpdate);
session.off('ralphCompletionDetected', listenerRefs.ralphCompletionDetected);
session.off('ralphStatusBlockDetected', listenerRefs.ralphStatusBlockDetected);
session.off('ralphCircuitBreakerUpdate', listenerRefs.ralphCircuitBreakerUpdate);
session.off('ralphExitGateMet', listenerRefs.ralphExitGateMet);
session.off('bashToolStart', listenerRefs.bashToolStart);
session.off('bashToolEnd', listenerRefs.bashToolEnd);
session.off('bashToolsUpdate', listenerRefs.bashToolsUpdate);
this.sessionListenerRefs.delete(session.id);
}
} catch (err) {
console.error(`[Server] Error cleaning up session resources on exit for ${session.id}:`, err);
}
},
working: () => {