Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,5 @@ The lock file will prevent accidental duplicate starts.
<!-- CODEUI-RULES -->
## Custom Rules

if it's a new feature, try to use a test cli unless it would be much easier to just have the user do a manual test (this is usually the case for visual features).. if it doesn't have functionality to do the test then add it. you can also use playwright mcp or curl if either of these would be easier. make sure you have enough logging to debug any issues.. if you create any test files, clean them up when you are done. NEVER TOUCH PORT 4001 or 5173.. if you are having issues with public apis, lookup examples online. NEVER create summary reports!
if it's a new feature, try to use a test cli unless it would be much easier to just have the user do a manual test (this is usually the case for visual features).. if it doesn't have functionality to do the test then add it. you can also use playwright mcp or curl if either of these would be easier. make sure you have enough logging to debug any issues.. if you create any test files, clean them up when you are done. NEVER TOUCH PORT 4001 or 5173.. if you are having issues with public apis, lookup examples online. NEVER create summary reports! NEVER push to main without explicit user approval. NEVER commit and push without letting the user test/validate changes first. Always ask before committing and before pushing. All changes should go through PRs, not direct pushes to main.
<!-- /CODEUI-RULES -->
28 changes: 23 additions & 5 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2200,17 +2200,34 @@ export async function createApp(basePath?: string) {
const platform = process.platform;
let cmd: string;
let args: string[];
const lastBrowsed = workspaceStore.getLastBrowsedPath();

if (platform === 'darwin') {
const scriptParts = ['POSIX path of (choose folder with prompt "Select a workspace folder"'];
if (lastBrowsed) {
const safe = lastBrowsed.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
scriptParts.push(` default location POSIX file "${safe}"`);
}
scriptParts.push(')');
cmd = 'osascript';
args = ['-e', 'POSIX path of (choose folder with prompt "Select reference folder")'];
args = ['-e', scriptParts.join('')];
} else if (platform === 'win32') {
const initialDir = lastBrowsed
? `$f.SelectedPath = [System.IO.Path]::GetFullPath("${lastBrowsed.replace(/"/g, '')}"); `
: '';
cmd = 'powershell';
args = ['-Command', `Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = 'Select reference folder'; if ($f.ShowDialog() -eq 'OK') { $f.SelectedPath } else { '' }`];
args = ['-STA', '-NoProfile', '-Command', `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Application]::EnableVisualStyles(); $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = 'Select a workspace folder'; $f.ShowNewFolderButton = $true; ${initialDir}if ($f.ShowDialog() -eq 'OK') { $f.SelectedPath } else { '' }`];
} else {
// Linux - try zenity, then kdialog
cmd = 'zenity';
args = ['--file-selection', '--directory', '--title=Select reference folder'];
// Linux - try zenity first, fall back to kdialog
try {
require('child_process').execFileSync('which', ['zenity'], { stdio: 'ignore' });
cmd = 'zenity';
args = ['--file-selection', '--directory', '--title=Select a workspace folder'];
if (lastBrowsed) args.push(`--filename=${lastBrowsed}/`);
} catch {
cmd = 'kdialog';
args = ['--getexistingdirectory', lastBrowsed || process.env['HOME'] || '/', '--title', 'Select a workspace folder'];
}
}

const child = spawn(cmd, args);
Expand All @@ -2221,6 +2238,7 @@ export async function createApp(basePath?: string) {
child.on('close', (code: number | null) => {
const path = stdout.trim();
if (code === 0 && path) {
workspaceStore.setLastBrowsedPath(path);
res.json({ success: true, path });
} else {
// User cancelled or error
Expand Down
48 changes: 29 additions & 19 deletions backend/src/task-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawn, IPty } from 'node-pty';
import { EventEmitter } from 'events';
import { Task, TaskState, TaskGitState, WaitingInputType, BackendType, PORTS, TaskTokenUsage } from '@claudia/shared';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { dirname, join, resolve } from 'path';
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, renameSync, appendFileSync, statSync, openSync, readSync, closeSync } from 'fs';
import { tmpdir } from 'os';
import { execSync } from 'child_process';
Expand Down Expand Up @@ -334,18 +334,18 @@ export class TaskSpawner extends EventEmitter {
? envReapInterval
: 10 * 60 * 1000;

// History file cap config. Default: rotate at 2MB, keep last 512KB tail.
// The 512KB keep-tail matches MAX_HISTORY_TO_SEND (what clients see)
// and MAX_RECONNECT_HISTORY (what we load on reconnect), so no memory
// is wasted storing history that will never be displayed.
// History file cap config. Default: rotate at 10MB, keep last 5MB tail.
// Files on disk are kept large to preserve full scrollback history.
// Memory loading on reconnect is capped separately (MAX_RECONNECT_HISTORY
// = 512KB) so large files don't cause OOM.
const envMaxBytes = parseInt(process.env.HISTORY_FILE_MAX_BYTES || '', 10);
this.historyFileMaxBytes = !isNaN(envMaxBytes) && envMaxBytes >= 0
? envMaxBytes
: 2 * 1024 * 1024;
: 10 * 1024 * 1024;
const envKeepBytes = parseInt(process.env.HISTORY_FILE_KEEP_BYTES || '', 10);
this.historyFileKeepBytes = !isNaN(envKeepBytes) && envKeepBytes > 0
? envKeepBytes
: 512 * 1024;
: 5 * 1024 * 1024;

// Initialize backend based on config
this.backendType = configStore?.getBackend() || 'claude-code';
Expand Down Expand Up @@ -625,11 +625,19 @@ export class TaskSpawner extends EventEmitter {
enabledMcpjsonServers: serverNames
};

// The Claudia project root — skip syncing to our own directory to avoid
// triggering tsx watch restarts from file writes in the project tree.
const selfRoot = resolve(join(__dirname, '..', '..'));

for (const workspaceId of workspaceIds) {
if (!existsSync(workspaceId)) {
logger.warn('Workspace does not exist, skipping MCP sync', { workspaceId });
continue;
}
if (resolve(workspaceId) === selfRoot) {
logger.info('Skipping MCP sync for Claudia\'s own workspace (prevents tsx watch restart)', { workspaceId });
continue;
}

// Write .mcp.json
const workspaceMcpFile = `${workspaceId}/.mcp.json`;
Expand Down Expand Up @@ -1237,21 +1245,19 @@ export class TaskSpawner extends EventEmitter {
return;
}

// Only auto-reconnect tasks that were mid-turn (shouldContinue=true) when the server
// restarted. Idle tasks still have wasInterrupted=true for display purposes but
// reconnect on-demand (when the user clicks them) to avoid spawning too many PTY
// processes and MCP servers on startup.
const MAX_STALE_AGE_MS = 60 * 60 * 1000; // 1 hour
// Auto-reconnect ALL tasks that were recently active (within the last 2 hours).
// tsx watch restarts happen frequently during development, and leaving all tasks
// disconnected is extremely disruptive. Reconnection is batched (2 at a time with
// delays) to prevent resource exhaustion from spawning too many PTY + MCP processes.
// Mid-turn tasks (shouldContinue=true) get priority in the sort order.
const MAX_STALE_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
const now = Date.now();
const tasksToReconnect = disconnectedIds.filter(id => {
const task = this.disconnectedTasks.get(id);
if (!task || !task.shouldContinue) return false;
// Skip stale tasks — they were interrupted long ago and should reconnect on-demand
if (!task || !task.wasInterrupted) return false;
if (!task.sessionId) return false; // can't reconnect without a session
const lastActive = task.lastActivity ? new Date(task.lastActivity).getTime() : 0;
if (now - lastActive > MAX_STALE_AGE_MS) {
console.log(`[TaskSpawner] Skipping stale task ${id} (last active ${Math.round((now - lastActive) / 60000)}m ago), clearing shouldContinue`);
task.shouldContinue = false;
task.wasInterrupted = false;
return false;
}
return true;
Expand All @@ -1264,10 +1270,14 @@ export class TaskSpawner extends EventEmitter {
return;
}

// Sort by most recently active first so the most important tasks reconnect first
// Sort: mid-turn tasks first (shouldContinue=true), then by most recent activity
tasksToReconnect.sort((a, b) => {
const taskA = this.disconnectedTasks.get(a);
const taskB = this.disconnectedTasks.get(b);
// shouldContinue tasks get highest priority
const prioA = taskA?.shouldContinue ? 1 : 0;
const prioB = taskB?.shouldContinue ? 1 : 0;
if (prioA !== prioB) return prioB - prioA;
const timeA = taskA?.lastActivity ? new Date(taskA.lastActivity).getTime() : 0;
const timeB = taskB?.lastActivity ? new Date(taskB.lastActivity).getTime() : 0;
return timeB - timeA; // most recent first
Expand Down Expand Up @@ -3061,7 +3071,7 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have
private getCombinedHistory(task: InternalTask): string | null {
// Limit history sent to frontend. Smaller = faster xterm rendering.
// 512KB is ~128 full terminal screens, well beyond what's visually useful.
const MAX_HISTORY_TO_SEND = 512 * 1024; // 512KB max
const MAX_HISTORY_TO_SEND = 2 * 1024 * 1024; // 2MB max

// Handle lazy loading from file
if (!task.previousHistory && !task.lazyHistoryBase64) {
Expand Down
Loading