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
24 changes: 24 additions & 0 deletions src/main/sandbox/lima-agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,30 @@ class SandboxAgent {
case 'runClaudeCode':
return this.runClaudeCode(params as Parameters<typeof this.runClaudeCode>[0]);

case 'batch': {
// Execute multiple operations in a single round-trip
const operations = params.operations as Array<{ method: string; params: Record<string, unknown> }>;
if (!Array.isArray(operations)) {
throw new Error('batch requires an operations array');
}
const results: Array<{ success: boolean; result?: unknown; error?: string }> = [];
for (const op of operations) {
try {
const fakeRequest: JSONRPCRequest = {
jsonrpc: '2.0',
id: 'batch',
method: op.method,
params: op.params,
};
const result = await this.handleRequest(fakeRequest);
results.push({ success: true, result });
} catch (err) {
results.push({ success: false, error: err instanceof Error ? err.message : String(err) });
}
}
return { results };
}

case 'shutdown':
return this.shutdown();

Expand Down
162 changes: 91 additions & 71 deletions src/main/sandbox/lima-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,87 +189,82 @@ export class LimaBridge implements SandboxExecutor {
};
}

// Check if Node.js is available
let nodeAvailable = false;
let nodeVersion = '';
try {
const { stdout } = await execLimaShellWithRetry('node --version', 10000);
nodeVersion = stdout.trim();
if (nodeVersion.startsWith('v')) {
nodeAvailable = true;
log('[Lima] Node.js found:', nodeVersion);
}
} catch (error) {
if (!isLimaShellConnectionError(error)) {
// Try with nvm
// Run all dependency checks in parallel for faster status detection
const [nodeResult, pythonResult, claudeResult] = await Promise.allSettled([
// Check Node.js
(async () => {
try {
const { stdout } = await execLimaShellWithRetry('node --version', 10000);
const ver = stdout.trim();
if (ver.startsWith('v')) return { available: true, version: ver };
} catch (error) {
if (!isLimaShellConnectionError(error)) {
try {
const { stdout } = await execLimaShellWithRetry(
'bash -c "source ~/.nvm/nvm.sh 2>/dev/null && node --version"',
10000
);
const ver = stdout.trim();
if (ver.startsWith('v')) return { available: true, version: ver + ' (nvm)' };
} catch { /* ignore */ }
}
}
return { available: false, version: '' };
})(),
// Check Python and pip in a single shell invocation
(async () => {
try {
const { stdout } = await execLimaShellWithRetry(
'bash -c "source ~/.nvm/nvm.sh 2>/dev/null && node --version"',
'bash -c "echo PYTHON:$(python3 --version 2>&1); echo PIP:$(python3 -m pip --version 2>&1 || echo MISSING)"',
10000
);
nodeVersion = stdout.trim();
if (nodeVersion.startsWith('v')) {
nodeAvailable = true;
nodeVersion += ' (nvm)';
log('[Lima] Node.js found via nvm:', nodeVersion);
}
const pythonMatch = stdout.match(/PYTHON:(Python [\d.]+)/);
const pipMissing = stdout.includes('PIP:MISSING');
return {
available: !!pythonMatch,
version: pythonMatch ? pythonMatch[1] : '',
pipAvailable: !!pythonMatch && !pipMissing,
};
} catch {
log('[Lima] Node.js not found');
return { available: false, version: '', pipAvailable: false };
}
} else {
log('[Lima] Node.js check failed: SSH not ready');
}
}

// Check Python
let pythonAvailable = false;
let pipAvailable = false;
let pythonVersion = '';
try {
const { stdout } = await execLimaShellWithRetry('python3 --version', 10000);
pythonVersion = stdout.trim();
if (pythonVersion.startsWith('Python')) {
pythonAvailable = true;
log('[Lima] Python found:', pythonVersion);

// Check pip
})(),
// Check claude-code
(async () => {
try {
await execLimaShellWithRetry('python3 -m pip --version', 10000);
pipAvailable = true;
await execLimaShellWithRetry(
'bash -c "source ~/.nvm/nvm.sh 2>/dev/null; which claude"',
10000
);
return true;
} catch {
log('[Lima] pip not available');
return false;
}
}
} catch {
log('[Lima] Python not found');
}
})(),
]);

// Check claude-code
let claudeCodeAvailable = false;
if (nodeAvailable) {
try {
await execLimaShellWithRetry(
'bash -c "source ~/.nvm/nvm.sh 2>/dev/null; which claude"',
10000
);
claudeCodeAvailable = true;
log('[Lima] claude-code found');
} catch {
log('[Lima] claude-code not found');
}
}
const node = nodeResult.status === 'fulfilled' ? nodeResult.value : { available: false, version: '' };
const python = pythonResult.status === 'fulfilled' ? pythonResult.value : { available: false, version: '', pipAvailable: false };
const claudeCodeAvailable = claudeResult.status === 'fulfilled' ? claudeResult.value : false;

if (node.available) log('[Lima] Node.js found:', node.version);
else log('[Lima] Node.js not found');
if (python.available) log('[Lima] Python found:', python.version);
else log('[Lima] Python not found');
if (claudeCodeAvailable) log('[Lima] claude-code found');
else log('[Lima] claude-code not found');

return {
available: true,
instanceExists,
instanceRunning,
instanceName: LIMA_INSTANCE_NAME,
nodeAvailable,
pythonAvailable,
pipAvailable,
claudeCodeAvailable,
version: nodeVersion,
pythonVersion,
nodeAvailable: node.available,
pythonAvailable: python.available,
pipAvailable: python.pipAvailable,
claudeCodeAvailable: node.available && claudeCodeAvailable,
version: node.version,
pythonVersion: python.version,
};
} catch (error) {
log('[Lima] Error checking status:', error);
Expand Down Expand Up @@ -623,8 +618,12 @@ export class LimaBridge implements SandboxExecutor {
if (!started) {
throw new Error('Failed to start Lima instance');
}
// Re-check status
status = await LimaBridge.checkLimaStatus();
status.instanceRunning = true;
// Only re-check dependency availability, not instance status we already know
const freshStatus = await LimaBridge.checkLimaStatus();
status.nodeAvailable = freshStatus.nodeAvailable;
status.pythonAvailable = freshStatus.pythonAvailable;
status.pipAvailable = freshStatus.pipAvailable;
}

// Dependencies should already be installed by bootstrap
Expand Down Expand Up @@ -709,23 +708,25 @@ export class LimaBridge implements SandboxExecutor {
logError('[Lima] Agent process error:', error);
});

// Wait for agent to be ready
// Wait for agent to be ready with exponential backoff
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Lima agent startup timeout'));
}, 30000);

let retryDelay = 100; // Start checking quickly
const checkReady = async () => {
try {
await this.sendRequest('ping', {});
await this.sendRequest('ping', {}, 3000);
clearTimeout(timeout);
resolve();
} catch {
setTimeout(checkReady, 500);
retryDelay = Math.min(retryDelay * 1.5, 2000); // Exponential backoff, cap at 2s
setTimeout(checkReady, retryDelay);
}
};

setTimeout(checkReady, 1000);
setTimeout(checkReady, 200); // Start checking after 200ms instead of 1s
});

log('[Lima] Agent is ready');
Expand Down Expand Up @@ -797,6 +798,25 @@ export class LimaBridge implements SandboxExecutor {
});
}

/**
* Send a batch of operations in a single IPC round-trip.
* Useful for reducing overhead when multiple independent operations are needed.
*/
async sendBatchRequest(
operations: Array<{ method: string; params: Record<string, unknown> }>,
timeoutMs: number = 60000
): Promise<Array<{ success: boolean; result?: unknown; error?: string }>> {
if (!this.isInitialized) {
throw new Error('Lima bridge not initialized');
}

const result = await this.sendRequest<{
results: Array<{ success: boolean; result?: unknown; error?: string }>;
}>('batch', { operations }, timeoutMs);

return result.results;
}

/**
* Execute a command in Lima
*/
Expand Down
20 changes: 11 additions & 9 deletions src/main/sandbox/lima-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,17 +128,19 @@ export class LimaSync {
const excludeArgs = SYNC_EXCLUDES.map(e => `--exclude="${e}"`).join(' ');

// Sync files from macOS to sandbox (within Lima VM)
const rsyncCmd = `rsync -av --delete ${excludeArgs} "${limaSourcePath}/" "${sandboxPath}/"`;
// Use -rlptD instead of -a to skip owner/group (faster), add --info=progress2 for stats
const rsyncCmd = `rsync -rlptD --delete ${excludeArgs} "${limaSourcePath}/" "${sandboxPath}/"`;
log(`[LimaSync] Running: ${rsyncCmd}`);

await this.limaExec(rsyncCmd, 300000); // 5 min timeout

// Count files and get size
const countResult = await this.limaExec(`find "${sandboxPath}" -type f | wc -l`);
const sizeResult = await this.limaExec(`du -sb "${sandboxPath}" | cut -f1`);

const fileCount = parseInt(countResult.stdout.trim()) || 0;
const totalSize = parseInt(sizeResult.stdout.trim()) || 0;
// Count files and get size in a single command to reduce SSH overhead
const statsResult = await this.limaExec(
`echo "$(find "${sandboxPath}" -type f | wc -l) $(du -sb "${sandboxPath}" | cut -f1)"`
);
const [countStr, sizeStr] = statsResult.stdout.trim().split(/\s+/);
const fileCount = parseInt(countStr) || 0;
const totalSize = parseInt(sizeStr) || 0;

// Store session info
const session: LimaSyncSession = {
Expand Down Expand Up @@ -201,8 +203,8 @@ export class LimaSync {

// Sync back to macOS (Lima mounts /Users directly)
// NOTE: We use --delete to ensure files deleted/moved in sandbox are also deleted locally
// This is important for file organization tasks where files are moved to new locations
const rsyncCmd = `rsync -av --delete ${excludeArgs} "${session.sandboxPath}/" "${limaDestPath}/"`;
// Use -rlptD instead of -a to skip owner/group (faster)
const rsyncCmd = `rsync -rlptD --delete ${excludeArgs} "${session.sandboxPath}/" "${limaDestPath}/"`;
log(`[LimaSync] Running: ${rsyncCmd}`);

await this.limaExec(rsyncCmd, 300000); // 5 min timeout
Expand Down
13 changes: 10 additions & 3 deletions src/main/sandbox/sandbox-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,16 @@ export class SandboxBootstrap {
return { mode: 'native', limaStatus, error: 'Lima instance start failed' };
}
limaStatus.instanceRunning = true;

// Re-check status after starting
limaStatus = await LimaBridge.checkLimaStatus();

// Only re-check dependency status (not instance existence/running which we already know)
// This avoids redundant limactl list + SSH connection checks
const freshStatus = await LimaBridge.checkLimaStatus();
limaStatus.nodeAvailable = freshStatus.nodeAvailable;
limaStatus.pythonAvailable = freshStatus.pythonAvailable;
limaStatus.pipAvailable = freshStatus.pipAvailable;
limaStatus.claudeCodeAvailable = freshStatus.claudeCodeAvailable;
limaStatus.version = freshStatus.version;
limaStatus.pythonVersion = freshStatus.pythonVersion;
}

// Phase 4: Install Node.js if needed
Expand Down
21 changes: 12 additions & 9 deletions src/main/sandbox/sandbox-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,20 @@ export class SandboxSync {
const excludeArgs = SYNC_EXCLUDES.map(e => `--exclude="${e}"`).join(' ');

// Sync files from Windows to sandbox
const rsyncCmd = `rsync -av --delete ${excludeArgs} "${wslSourcePath}/" "${sandboxPath}/"`;
// Use -rlptD instead of -a to skip owner/group (faster cross-filesystem sync)
const rsyncCmd = `rsync -rlptD --delete ${excludeArgs} "${wslSourcePath}/" "${sandboxPath}/"`;
log(`[SandboxSync] Running: ${rsyncCmd}`);

await this.wslExec(distro, rsyncCmd, 300000); // 5 min timeout

// Count files and get size
const countResult = await this.wslExec(distro, `find "${sandboxPath}" -type f | wc -l`);
const sizeResult = await this.wslExec(distro, `du -sb "${sandboxPath}" | cut -f1`);

const fileCount = parseInt(countResult.stdout.trim()) || 0;
const totalSize = parseInt(sizeResult.stdout.trim()) || 0;
// Count files and get size in a single command to reduce WSL overhead
const statsResult = await this.wslExec(
distro,
`echo "$(find "${sandboxPath}" -type f | wc -l) $(du -sb "${sandboxPath}" | cut -f1)"`
);
const [countStr, sizeStr] = statsResult.stdout.trim().split(/\s+/);
const fileCount = parseInt(countStr) || 0;
const totalSize = parseInt(sizeStr) || 0;

// Store session info
const session: SyncSession = {
Expand Down Expand Up @@ -207,8 +210,8 @@ export class SandboxSync {

// Sync back to Windows (via /mnt/)
// NOTE: We use --delete to ensure files deleted/moved in sandbox are also deleted locally
// This is important for file organization tasks where files are moved to new locations
const rsyncCmd = `rsync -av --delete ${excludeArgs} "${session.sandboxPath}/" "${wslDestPath}/"`;
// Use -rlptD instead of -a to skip owner/group (faster cross-filesystem sync)
const rsyncCmd = `rsync -rlptD --delete ${excludeArgs} "${session.sandboxPath}/" "${wslDestPath}/"`;
log(`[SandboxSync] Running: ${rsyncCmd}`);

await this.wslExec(session.distro, rsyncCmd, 300000); // 5 min timeout
Expand Down
24 changes: 24 additions & 0 deletions src/main/sandbox/wsl-agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,30 @@ class SandboxAgent {
case 'runClaudeCode':
return this.runClaudeCode(params as Parameters<typeof this.runClaudeCode>[0]);

case 'batch': {
// Execute multiple operations in a single round-trip
const operations = params.operations as Array<{ method: string; params: Record<string, unknown> }>;
if (!Array.isArray(operations)) {
throw new Error('batch requires an operations array');
}
const results: Array<{ success: boolean; result?: unknown; error?: string }> = [];
for (const op of operations) {
try {
const fakeRequest: JSONRPCRequest = {
jsonrpc: '2.0',
id: 'batch',
method: op.method,
params: op.params,
};
const result = await this.handleRequest(fakeRequest);
results.push({ success: true, result });
} catch (err) {
results.push({ success: false, error: err instanceof Error ? err.message : String(err) });
}
}
return { results };
}

case 'shutdown':
return this.shutdown();

Expand Down
Loading
Loading