Fix/cleanup 2.7.5 (#1271)
* fix(frontend): resolve preload API duplicates and terminal session corruption - Remove duplicate API spreads (IdeationAPI, InsightsAPI, GitLabAPI) from createElectronAPI() - these are already included via createAgentAPI() - Fix "object is not iterable" error in Electron sandbox renderer - Implement atomic writes for TerminalSessionStore using temp file + rename - Add backup rotation and automatic recovery from corrupted session files - Prevents data loss on app crash or interrupted writes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(hooks): use perl for cross-platform README version sync BSD sed (macOS) doesn't support the {block} syntax with address ranges. Replace sed with perl for the download links update which works consistently across macOS and Linux. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: bump version to 2.7.5 * perf(frontend): optimize terminal session store async operations - Replace sync existsSync() with async fileExists() helper in saveAsync() - Add pendingDeleteTimers Map to prevent timer accumulation on rapid deletes - Cancel existing cleanup timers before creating new ones - Add comprehensive unit tests (28 tests) covering: - Atomic write pattern (temp file -> backup rotation -> rename) - Backup recovery from corrupted main file - Race condition prevention via pendingDelete - Write serialization with writeInProgress/writePending - Timer cleanup for pendingDeleteTimers - Session CRUD operations, output buffer, display order Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+4
-2
@@ -62,8 +62,9 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update beta download links (within BETA_DOWNLOADS section only)
|
||||
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
|
||||
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
perl -i -pe 'if (/<!-- BETA_DOWNLOADS -->/ .. /<!-- BETA_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
|
||||
done
|
||||
else
|
||||
# STABLE: Update stable sections and top badge
|
||||
@@ -78,8 +79,9 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable download links (within STABLE_DOWNLOADS section only)
|
||||
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
perl -i -pe 'if (/<!-- STABLE_DOWNLOADS -->/ .. /<!-- STABLE_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
@@ -16,17 +16,17 @@
|
||||
### Stable Release
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.4)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
|
||||
<!-- STABLE_VERSION_BADGE_END -->
|
||||
|
||||
<!-- STABLE_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.4-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.4-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.4-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.4-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.4-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-amd64.deb) |
|
||||
| **Windows** | [Auto-Claude-2.7.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.4-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-x86_64.flatpak) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.4"
|
||||
__version__ = "2.7.5"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.4",
|
||||
"version": "2.7.5",
|
||||
"type": "module",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Auto-Claude",
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* Unit tests for Terminal Session Store
|
||||
* Tests atomic writes, backup recovery, race condition prevention, and write serialization
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Test directories
|
||||
const TEST_DIR = '/tmp/terminal-session-store-test';
|
||||
const USER_DATA_PATH = path.join(TEST_DIR, 'userData');
|
||||
const SESSIONS_DIR = path.join(USER_DATA_PATH, 'sessions');
|
||||
const STORE_PATH = path.join(SESSIONS_DIR, 'terminals.json');
|
||||
const TEMP_PATH = path.join(SESSIONS_DIR, 'terminals.json.tmp');
|
||||
const BACKUP_PATH = path.join(SESSIONS_DIR, 'terminals.json.backup');
|
||||
const TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
|
||||
|
||||
// Mock Electron before importing the store
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => {
|
||||
if (name === 'userData') return USER_DATA_PATH;
|
||||
return TEST_DIR;
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
// Setup test directories
|
||||
function setupTestDirs(): void {
|
||||
mkdirSync(SESSIONS_DIR, { recursive: true });
|
||||
mkdirSync(TEST_PROJECT_PATH, { recursive: true });
|
||||
}
|
||||
|
||||
// Cleanup test directories
|
||||
function cleanupTestDirs(): void {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Create a valid session data structure
|
||||
function createValidStoreData(sessionsByDate: Record<string, Record<string, unknown[]>> = {}): string {
|
||||
return JSON.stringify({
|
||||
version: 2,
|
||||
sessionsByDate
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
// Create a test session
|
||||
function createTestSession(overrides: Partial<{
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
projectPath: string;
|
||||
isClaudeMode: boolean;
|
||||
outputBuffer: string;
|
||||
createdAt: string;
|
||||
lastActiveAt: string;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'test-session-1',
|
||||
title: overrides.title ?? 'Test Terminal',
|
||||
cwd: overrides.cwd ?? TEST_PROJECT_PATH,
|
||||
projectPath: overrides.projectPath ?? TEST_PROJECT_PATH,
|
||||
isClaudeMode: overrides.isClaudeMode ?? false,
|
||||
outputBuffer: overrides.outputBuffer ?? 'test output',
|
||||
createdAt: overrides.createdAt ?? new Date().toISOString(),
|
||||
lastActiveAt: overrides.lastActiveAt ?? new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
describe('TerminalSessionStore', () => {
|
||||
beforeEach(async () => {
|
||||
cleanupTestDirs();
|
||||
setupTestDirs();
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTestDirs();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create sessions directory if not exists', async () => {
|
||||
rmSync(SESSIONS_DIR, { recursive: true, force: true });
|
||||
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
new TerminalSessionStore();
|
||||
|
||||
expect(existsSync(SESSIONS_DIR)).toBe(true);
|
||||
});
|
||||
|
||||
it('should initialize with empty data when no store file exists', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
const data = store.getAllSessions();
|
||||
expect(data.version).toBe(2);
|
||||
expect(data.sessionsByDate).toEqual({});
|
||||
});
|
||||
|
||||
it('should load existing valid store data', async () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const existingData = createValidStoreData({
|
||||
[today]: {
|
||||
[TEST_PROJECT_PATH]: [createTestSession()]
|
||||
}
|
||||
});
|
||||
writeFileSync(STORE_PATH, existingData);
|
||||
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
const sessions = store.getSessions(TEST_PROJECT_PATH);
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].id).toBe('test-session-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('atomic writes', () => {
|
||||
it('should write to temp file then rename atomically', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
store.saveSession(createTestSession());
|
||||
|
||||
// Main file should exist after save
|
||||
expect(existsSync(STORE_PATH)).toBe(true);
|
||||
// Temp file should be cleaned up
|
||||
expect(existsSync(TEMP_PATH)).toBe(false);
|
||||
|
||||
// Verify content
|
||||
const content = JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
|
||||
expect(content.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should rotate current file to backup before overwriting', async () => {
|
||||
// Create initial store with one session
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const initialData = createValidStoreData({
|
||||
[today]: {
|
||||
[TEST_PROJECT_PATH]: [createTestSession({ id: 'original-session' })]
|
||||
}
|
||||
});
|
||||
writeFileSync(STORE_PATH, initialData);
|
||||
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Save a new session (triggers backup rotation)
|
||||
store.saveSession(createTestSession({ id: 'new-session' }));
|
||||
|
||||
// Backup should exist with original data
|
||||
expect(existsSync(BACKUP_PATH)).toBe(true);
|
||||
const backupContent = JSON.parse(readFileSync(BACKUP_PATH, 'utf-8'));
|
||||
const backupSessions = backupContent.sessionsByDate[today][TEST_PROJECT_PATH];
|
||||
expect(backupSessions.some((s: { id: string }) => s.id === 'original-session')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not backup corrupted files', async () => {
|
||||
// Create corrupted store file
|
||||
writeFileSync(STORE_PATH, 'not valid json {{{');
|
||||
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Save a session
|
||||
store.saveSession(createTestSession());
|
||||
|
||||
// Backup should NOT contain the corrupted data
|
||||
if (existsSync(BACKUP_PATH)) {
|
||||
const backupContent = readFileSync(BACKUP_PATH, 'utf-8');
|
||||
expect(backupContent).not.toContain('not valid json');
|
||||
}
|
||||
});
|
||||
|
||||
it('should clean up temp file on error', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Force an error by making the directory read-only (if possible)
|
||||
// This test mainly verifies the code path exists
|
||||
store.saveSession(createTestSession());
|
||||
|
||||
// Temp file should not exist after successful save
|
||||
expect(existsSync(TEMP_PATH)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup recovery', () => {
|
||||
it('should recover from corrupted main file using backup', async () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Create valid backup
|
||||
const backupData = createValidStoreData({
|
||||
[today]: {
|
||||
[TEST_PROJECT_PATH]: [createTestSession({ id: 'recovered-session' })]
|
||||
}
|
||||
});
|
||||
writeFileSync(BACKUP_PATH, backupData);
|
||||
|
||||
// Create corrupted main file
|
||||
writeFileSync(STORE_PATH, 'corrupted {{{ json');
|
||||
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Should recover from backup
|
||||
const sessions = store.getSessions(TEST_PROJECT_PATH);
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].id).toBe('recovered-session');
|
||||
});
|
||||
|
||||
it('should restore main file from backup after recovery', async () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Create valid backup
|
||||
const backupData = createValidStoreData({
|
||||
[today]: {
|
||||
[TEST_PROJECT_PATH]: [createTestSession()]
|
||||
}
|
||||
});
|
||||
writeFileSync(BACKUP_PATH, backupData);
|
||||
|
||||
// Create corrupted main file
|
||||
writeFileSync(STORE_PATH, 'corrupted');
|
||||
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
new TerminalSessionStore();
|
||||
|
||||
// Main file should now be valid
|
||||
const mainContent = JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
|
||||
expect(mainContent.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should start fresh if both main and backup are corrupted', async () => {
|
||||
writeFileSync(STORE_PATH, 'corrupted main');
|
||||
writeFileSync(BACKUP_PATH, 'corrupted backup');
|
||||
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
const data = store.getAllSessions();
|
||||
expect(data.version).toBe(2);
|
||||
expect(data.sessionsByDate).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('race condition prevention', () => {
|
||||
it('should not resurrect deleted sessions in async save', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Create and save a session
|
||||
const session = createTestSession({ id: 'to-be-deleted' });
|
||||
store.saveSession(session);
|
||||
|
||||
// Verify session exists
|
||||
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(1);
|
||||
|
||||
// Delete the session
|
||||
store.removeSession(TEST_PROJECT_PATH, 'to-be-deleted');
|
||||
|
||||
// Try to save the same session again (simulating in-flight async save)
|
||||
await store.saveSessionAsync(session);
|
||||
|
||||
// Session should NOT be resurrected
|
||||
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should track session in pendingDelete after removal', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
store.saveSession(createTestSession({ id: 'session-1' }));
|
||||
store.removeSession(TEST_PROJECT_PATH, 'session-1');
|
||||
|
||||
// Attempt to save the deleted session
|
||||
const result = await store.saveSessionAsync(createTestSession({ id: 'session-1' }));
|
||||
|
||||
// Session should not be saved (saveSessionAsync returns undefined when skipped)
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clean up pendingDelete after timeout', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
store.saveSession(createTestSession({ id: 'cleanup-test' }));
|
||||
store.removeSession(TEST_PROJECT_PATH, 'cleanup-test');
|
||||
|
||||
// Fast-forward past the cleanup timeout (5000ms)
|
||||
vi.advanceTimersByTime(5001);
|
||||
|
||||
// Now the session should be saveable again
|
||||
store.saveSession(createTestSession({ id: 'cleanup-test' }));
|
||||
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should prevent timer accumulation on rapid deletes', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Create a session
|
||||
store.saveSession(createTestSession({ id: 'rapid-delete' }));
|
||||
|
||||
// Delete the same session ID multiple times rapidly
|
||||
for (let i = 0; i < 100; i++) {
|
||||
store.removeSession(TEST_PROJECT_PATH, 'rapid-delete');
|
||||
}
|
||||
|
||||
// Fast-forward to trigger cleanup
|
||||
vi.advanceTimersByTime(5001);
|
||||
|
||||
// Should complete without issues (no timer accumulation)
|
||||
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('write serialization', () => {
|
||||
it('should serialize concurrent async writes', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Start multiple concurrent saves
|
||||
const promises = [
|
||||
store.saveSessionAsync(createTestSession({ id: 'session-1', title: 'First' })),
|
||||
store.saveSessionAsync(createTestSession({ id: 'session-2', title: 'Second' })),
|
||||
store.saveSessionAsync(createTestSession({ id: 'session-3', title: 'Third' }))
|
||||
];
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// All sessions should be saved
|
||||
const sessions = store.getSessions(TEST_PROJECT_PATH);
|
||||
expect(sessions).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should coalesce rapid writes using writePending flag', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Use real timers for this test since we need setImmediate to work
|
||||
vi.useRealTimers();
|
||||
|
||||
// Fire many rapid saves
|
||||
const promises: Promise<void>[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(store.saveSessionAsync(createTestSession({
|
||||
id: `rapid-${i}`,
|
||||
title: `Session ${i}`
|
||||
})));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// All sessions should be saved
|
||||
const sessions = store.getSessions(TEST_PROJECT_PATH);
|
||||
expect(sessions).toHaveLength(10);
|
||||
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('failure tracking', () => {
|
||||
it('should reset consecutive failures on successful save', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Successful save should work
|
||||
store.saveSession(createTestSession());
|
||||
|
||||
// Verify file was written
|
||||
expect(existsSync(STORE_PATH)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session CRUD operations', () => {
|
||||
it('should save and retrieve sessions', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
const session = createTestSession({
|
||||
id: 'crud-test',
|
||||
title: 'CRUD Test Terminal'
|
||||
});
|
||||
store.saveSession(session);
|
||||
|
||||
const retrieved = store.getSession(TEST_PROJECT_PATH, 'crud-test');
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.title).toBe('CRUD Test Terminal');
|
||||
});
|
||||
|
||||
it('should update existing sessions', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Save initial session
|
||||
store.saveSession(createTestSession({
|
||||
id: 'update-test',
|
||||
title: 'Original Title'
|
||||
}));
|
||||
|
||||
// Update the session
|
||||
store.saveSession(createTestSession({
|
||||
id: 'update-test',
|
||||
title: 'Updated Title'
|
||||
}));
|
||||
|
||||
const sessions = store.getSessions(TEST_PROJECT_PATH);
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].title).toBe('Updated Title');
|
||||
});
|
||||
|
||||
it('should remove sessions correctly', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
store.saveSession(createTestSession({ id: 'to-remove' }));
|
||||
store.saveSession(createTestSession({ id: 'to-keep' }));
|
||||
|
||||
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(2);
|
||||
|
||||
store.removeSession(TEST_PROJECT_PATH, 'to-remove');
|
||||
|
||||
const remaining = store.getSessions(TEST_PROJECT_PATH);
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].id).toBe('to-keep');
|
||||
});
|
||||
|
||||
it('should clear all sessions for a project', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
store.saveSession(createTestSession({ id: 'session-1' }));
|
||||
store.saveSession(createTestSession({ id: 'session-2' }));
|
||||
|
||||
store.clearProjectSessions(TEST_PROJECT_PATH);
|
||||
|
||||
expect(store.getSessions(TEST_PROJECT_PATH)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('output buffer management', () => {
|
||||
it('should limit output buffer size to MAX_OUTPUT_BUFFER', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Create session with large output buffer (> 100KB)
|
||||
const largeOutput = 'x'.repeat(150000);
|
||||
store.saveSession(createTestSession({
|
||||
id: 'large-buffer',
|
||||
outputBuffer: largeOutput
|
||||
}));
|
||||
|
||||
const session = store.getSession(TEST_PROJECT_PATH, 'large-buffer');
|
||||
expect(session?.outputBuffer.length).toBeLessThanOrEqual(100000);
|
||||
});
|
||||
|
||||
it('should update output buffer incrementally', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
store.saveSession(createTestSession({
|
||||
id: 'buffer-update',
|
||||
outputBuffer: 'initial'
|
||||
}));
|
||||
|
||||
store.updateOutputBuffer(TEST_PROJECT_PATH, 'buffer-update', ' appended');
|
||||
|
||||
const session = store.getSession(TEST_PROJECT_PATH, 'buffer-update');
|
||||
expect(session?.outputBuffer).toBe('initial appended');
|
||||
});
|
||||
});
|
||||
|
||||
describe('display order', () => {
|
||||
it('should update display orders for terminals', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
store.saveSession(createTestSession({ id: 'term-1' }));
|
||||
store.saveSession(createTestSession({ id: 'term-2' }));
|
||||
store.saveSession(createTestSession({ id: 'term-3' }));
|
||||
|
||||
store.updateDisplayOrders(TEST_PROJECT_PATH, [
|
||||
{ terminalId: 'term-1', displayOrder: 2 },
|
||||
{ terminalId: 'term-2', displayOrder: 0 },
|
||||
{ terminalId: 'term-3', displayOrder: 1 }
|
||||
]);
|
||||
|
||||
const sessions = store.getSessions(TEST_PROJECT_PATH);
|
||||
const term1 = sessions.find(s => s.id === 'term-1');
|
||||
const term2 = sessions.find(s => s.id === 'term-2');
|
||||
const term3 = sessions.find(s => s.id === 'term-3');
|
||||
|
||||
expect(term1?.displayOrder).toBe(2);
|
||||
expect(term2?.displayOrder).toBe(0);
|
||||
expect(term3?.displayOrder).toBe(1);
|
||||
});
|
||||
|
||||
it('should preserve display order on session update', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
store.saveSession(createTestSession({ id: 'ordered-term' }));
|
||||
store.updateDisplayOrders(TEST_PROJECT_PATH, [
|
||||
{ terminalId: 'ordered-term', displayOrder: 5 }
|
||||
]);
|
||||
|
||||
// Update session without displayOrder (simulating periodic output save)
|
||||
store.saveSession(createTestSession({
|
||||
id: 'ordered-term',
|
||||
outputBuffer: 'new output'
|
||||
}));
|
||||
|
||||
const session = store.getSession(TEST_PROJECT_PATH, 'ordered-term');
|
||||
expect(session?.displayOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version migration', () => {
|
||||
it('should migrate v1 data to v2 structure', async () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Create v1 format data
|
||||
const v1Data = JSON.stringify({
|
||||
version: 1,
|
||||
sessions: {
|
||||
[TEST_PROJECT_PATH]: [createTestSession()]
|
||||
}
|
||||
});
|
||||
writeFileSync(STORE_PATH, v1Data);
|
||||
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
// Should have migrated to v2 with today's date
|
||||
const data = store.getAllSessions();
|
||||
expect(data.version).toBe(2);
|
||||
expect(data.sessionsByDate[today]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('date-based organization', () => {
|
||||
it('should get available dates with session info', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
store.saveSession(createTestSession({ id: 'today-1' }));
|
||||
store.saveSession(createTestSession({ id: 'today-2' }));
|
||||
|
||||
const dates = store.getAvailableDates();
|
||||
|
||||
expect(dates).toHaveLength(1);
|
||||
expect(dates[0].sessionCount).toBe(2);
|
||||
expect(dates[0].label).toBe('Today');
|
||||
});
|
||||
|
||||
it('should filter available dates by project', async () => {
|
||||
const { TerminalSessionStore } = await import('../terminal-session-store');
|
||||
const store = new TerminalSessionStore();
|
||||
|
||||
const otherProjectPath = path.join(TEST_DIR, 'other-project');
|
||||
mkdirSync(otherProjectPath, { recursive: true });
|
||||
|
||||
store.saveSession(createTestSession({ projectPath: TEST_PROJECT_PATH }));
|
||||
store.saveSession(createTestSession({ id: 'other', projectPath: otherProjectPath }));
|
||||
|
||||
const dates = store.getAvailableDates(TEST_PROJECT_PATH);
|
||||
|
||||
expect(dates).toHaveLength(1);
|
||||
expect(dates[0].sessionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, promises as fsPromises } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, promises as fsPromises } from 'fs';
|
||||
import type { TerminalWorktreeConfig } from '../shared/types';
|
||||
|
||||
/**
|
||||
@@ -76,6 +76,8 @@ function getDateLabel(dateStr: string): string {
|
||||
*/
|
||||
export class TerminalSessionStore {
|
||||
private storePath: string;
|
||||
private tempPath: string;
|
||||
private backupPath: string;
|
||||
private data: SessionData;
|
||||
/**
|
||||
* Tracks session IDs that are being deleted to prevent async writes from
|
||||
@@ -83,6 +85,11 @@ export class TerminalSessionStore {
|
||||
* could complete after removeSession() and re-add deleted sessions.
|
||||
*/
|
||||
private pendingDelete: Set<string> = new Set();
|
||||
/**
|
||||
* Tracks cleanup timers for pendingDelete entries to prevent timer accumulation
|
||||
* when many sessions are deleted rapidly.
|
||||
*/
|
||||
private pendingDeleteTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
/**
|
||||
* Write serialization state - prevents concurrent async writes from
|
||||
* interleaving and potentially losing data.
|
||||
@@ -99,6 +106,8 @@ export class TerminalSessionStore {
|
||||
constructor() {
|
||||
const sessionsDir = join(app.getPath('userData'), 'sessions');
|
||||
this.storePath = join(sessionsDir, 'terminals.json');
|
||||
this.tempPath = join(sessionsDir, 'terminals.json.tmp');
|
||||
this.backupPath = join(sessionsDir, 'terminals.json.backup');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!existsSync(sessionsDir)) {
|
||||
@@ -113,54 +122,133 @@ export class TerminalSessionStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sessions from disk
|
||||
* Load sessions from disk with backup recovery
|
||||
*/
|
||||
private load(): SessionData {
|
||||
try {
|
||||
if (existsSync(this.storePath)) {
|
||||
const content = readFileSync(this.storePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
// Try loading from main file first
|
||||
const mainResult = this.tryLoadFile(this.storePath);
|
||||
if (mainResult.success && mainResult.data) {
|
||||
return mainResult.data;
|
||||
}
|
||||
|
||||
// Migrate from v1 to v2 structure
|
||||
if (data.version === 1 && data.sessions) {
|
||||
console.warn('[TerminalSessionStore] Migrating from v1 to v2 structure');
|
||||
const today = getDateString();
|
||||
const migratedData: SessionData = {
|
||||
version: STORE_VERSION,
|
||||
sessionsByDate: {
|
||||
[today]: data.sessions
|
||||
}
|
||||
};
|
||||
return migratedData;
|
||||
// If main file failed, try backup
|
||||
if (mainResult.error) {
|
||||
console.warn('[TerminalSessionStore] Main file corrupted, attempting backup recovery...');
|
||||
const backupResult = this.tryLoadFile(this.backupPath);
|
||||
if (backupResult.success && backupResult.data) {
|
||||
console.warn('[TerminalSessionStore] Successfully recovered from backup!');
|
||||
// Immediately save the recovered data to main file
|
||||
try {
|
||||
writeFileSync(this.storePath, JSON.stringify(backupResult.data, null, 2));
|
||||
console.warn('[TerminalSessionStore] Restored main file from backup');
|
||||
} catch (writeError) {
|
||||
console.error('[TerminalSessionStore] Failed to restore main file:', writeError);
|
||||
}
|
||||
|
||||
if (data.version === STORE_VERSION) {
|
||||
return data as SessionData;
|
||||
}
|
||||
|
||||
console.warn('[TerminalSessionStore] Version mismatch, resetting sessions');
|
||||
return { version: STORE_VERSION, sessionsByDate: {} };
|
||||
return backupResult.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TerminalSessionStore] Error loading sessions:', error);
|
||||
console.error('[TerminalSessionStore] Backup recovery failed, starting fresh');
|
||||
}
|
||||
|
||||
return { version: STORE_VERSION, sessionsByDate: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sessions to disk
|
||||
* Try to load and parse a session file
|
||||
*/
|
||||
private save(): void {
|
||||
private tryLoadFile(filePath: string): { success: boolean; data?: SessionData; error?: Error } {
|
||||
try {
|
||||
writeFileSync(this.storePath, JSON.stringify(this.data, null, 2));
|
||||
if (!existsSync(filePath)) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Migrate from v1 to v2 structure
|
||||
if (data.version === 1 && data.sessions) {
|
||||
console.warn('[TerminalSessionStore] Migrating from v1 to v2 structure');
|
||||
const today = getDateString();
|
||||
const migratedData: SessionData = {
|
||||
version: STORE_VERSION,
|
||||
sessionsByDate: {
|
||||
[today]: data.sessions
|
||||
}
|
||||
};
|
||||
return { success: true, data: migratedData };
|
||||
}
|
||||
|
||||
if (data.version === STORE_VERSION) {
|
||||
return { success: true, data: data as SessionData };
|
||||
}
|
||||
|
||||
console.warn('[TerminalSessionStore] Version mismatch, resetting sessions');
|
||||
return { success: false };
|
||||
} catch (error) {
|
||||
console.error('[TerminalSessionStore] Error saving sessions:', error);
|
||||
console.error(`[TerminalSessionStore] Error loading ${filePath}:`, error);
|
||||
return { success: false, error: error as Error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sessions to disk asynchronously (non-blocking)
|
||||
* Save sessions to disk using atomic write pattern:
|
||||
* 1. Write to temp file
|
||||
* 2. Rotate current file to backup
|
||||
* 3. Rename temp to target (atomic on most filesystems)
|
||||
*/
|
||||
private save(): void {
|
||||
try {
|
||||
const content = JSON.stringify(this.data, null, 2);
|
||||
|
||||
// Step 1: Write to temp file
|
||||
writeFileSync(this.tempPath, content);
|
||||
|
||||
// Step 2: Rotate current file to backup (if it exists and is valid)
|
||||
if (existsSync(this.storePath)) {
|
||||
try {
|
||||
// Verify current file is valid before backing up
|
||||
const currentContent = readFileSync(this.storePath, 'utf-8');
|
||||
JSON.parse(currentContent); // Throws if invalid
|
||||
// Current file is valid, rotate to backup
|
||||
if (existsSync(this.backupPath)) {
|
||||
unlinkSync(this.backupPath);
|
||||
}
|
||||
renameSync(this.storePath, this.backupPath);
|
||||
} catch {
|
||||
// Current file is corrupted, don't back it up - just delete
|
||||
console.warn('[TerminalSessionStore] Current file corrupted, not backing up');
|
||||
unlinkSync(this.storePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Atomic rename temp to target
|
||||
renameSync(this.tempPath, this.storePath);
|
||||
} catch (error) {
|
||||
console.error('[TerminalSessionStore] Error saving sessions:', error);
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
if (existsSync(this.tempPath)) {
|
||||
unlinkSync(this.tempPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if a file exists asynchronously
|
||||
*/
|
||||
private async fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fsPromises.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sessions to disk asynchronously (non-blocking) using atomic write pattern
|
||||
*
|
||||
* Safe to call from Electron main process without blocking the event loop.
|
||||
* Uses write serialization to prevent concurrent writes from losing data.
|
||||
@@ -175,13 +263,46 @@ export class TerminalSessionStore {
|
||||
|
||||
this.writeInProgress = true;
|
||||
try {
|
||||
await fsPromises.writeFile(this.storePath, JSON.stringify(this.data, null, 2));
|
||||
const content = JSON.stringify(this.data, null, 2);
|
||||
|
||||
// Step 1: Write to temp file
|
||||
await fsPromises.writeFile(this.tempPath, content);
|
||||
|
||||
// Step 2: Rotate current file to backup (if it exists and is valid)
|
||||
if (await this.fileExists(this.storePath)) {
|
||||
try {
|
||||
const currentContent = await fsPromises.readFile(this.storePath, 'utf-8');
|
||||
JSON.parse(currentContent); // Throws if invalid
|
||||
// Current file is valid, rotate to backup
|
||||
if (await this.fileExists(this.backupPath)) {
|
||||
await fsPromises.unlink(this.backupPath);
|
||||
}
|
||||
await fsPromises.rename(this.storePath, this.backupPath);
|
||||
} catch {
|
||||
// Current file is corrupted, don't back it up - just delete
|
||||
console.warn('[TerminalSessionStore] Current file corrupted, not backing up');
|
||||
await fsPromises.unlink(this.storePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Atomic rename temp to target
|
||||
await fsPromises.rename(this.tempPath, this.storePath);
|
||||
|
||||
// Reset failure counter on success
|
||||
this.consecutiveFailures = 0;
|
||||
} catch (error) {
|
||||
this.consecutiveFailures++;
|
||||
console.error('[TerminalSessionStore] Error saving sessions:', error);
|
||||
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
if (await this.fileExists(this.tempPath)) {
|
||||
await fsPromises.unlink(this.tempPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
// Warn about persistent failures that might indicate a real problem
|
||||
if (this.consecutiveFailures >= TerminalSessionStore.MAX_FAILURES_BEFORE_WARNING) {
|
||||
console.error(
|
||||
@@ -459,11 +580,20 @@ export class TerminalSessionStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
// Cancel any existing cleanup timer for this session (prevents timer accumulation
|
||||
// when the same session ID is deleted multiple times rapidly)
|
||||
const existingTimer = this.pendingDeleteTimers.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
// Keep the ID in pendingDelete for a short time to handle any in-flight
|
||||
// async operations, then clean up to prevent memory leaks
|
||||
setTimeout(() => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingDelete.delete(sessionId);
|
||||
this.pendingDeleteTimers.delete(sessionId);
|
||||
}, 5000);
|
||||
this.pendingDeleteTimers.set(sessionId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,11 +4,11 @@ import { TaskAPI, createTaskAPI } from './task-api';
|
||||
import { SettingsAPI, createSettingsAPI } from './settings-api';
|
||||
import { FileAPI, createFileAPI } from './file-api';
|
||||
import { AgentAPI, createAgentAPI } from './agent-api';
|
||||
import { IdeationAPI, createIdeationAPI } from './modules/ideation-api';
|
||||
import { InsightsAPI, createInsightsAPI } from './modules/insights-api';
|
||||
import type { IdeationAPI } from './modules/ideation-api';
|
||||
import type { InsightsAPI } from './modules/insights-api';
|
||||
import { AppUpdateAPI, createAppUpdateAPI } from './app-update-api';
|
||||
import { GitHubAPI, createGitHubAPI } from './modules/github-api';
|
||||
import { GitLabAPI, createGitLabAPI } from './modules/gitlab-api';
|
||||
import type { GitLabAPI } from './modules/gitlab-api';
|
||||
import { DebugAPI, createDebugAPI } from './modules/debug-api';
|
||||
import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api';
|
||||
import { McpAPI, createMcpAPI } from './modules/mcp-api';
|
||||
@@ -38,11 +38,8 @@ export const createElectronAPI = (): ElectronAPI => ({
|
||||
...createTaskAPI(),
|
||||
...createSettingsAPI(),
|
||||
...createFileAPI(),
|
||||
...createAgentAPI(),
|
||||
...createIdeationAPI(),
|
||||
...createInsightsAPI(),
|
||||
...createAgentAPI(), // Includes: Roadmap, Ideation, Insights, Changelog, Linear, GitHub, GitLab, Shell
|
||||
...createAppUpdateAPI(),
|
||||
...createGitLabAPI(),
|
||||
...createDebugAPI(),
|
||||
...createClaudeCodeAPI(),
|
||||
...createMcpAPI(),
|
||||
@@ -51,6 +48,7 @@ export const createElectronAPI = (): ElectronAPI => ({
|
||||
});
|
||||
|
||||
// Export individual API creators for potential use in tests or specialized contexts
|
||||
// Note: IdeationAPI, InsightsAPI, and GitLabAPI are included in AgentAPI
|
||||
export {
|
||||
createProjectAPI,
|
||||
createTerminalAPI,
|
||||
@@ -58,12 +56,9 @@ export {
|
||||
createSettingsAPI,
|
||||
createFileAPI,
|
||||
createAgentAPI,
|
||||
createIdeationAPI,
|
||||
createInsightsAPI,
|
||||
createAppUpdateAPI,
|
||||
createProfileAPI,
|
||||
createGitHubAPI,
|
||||
createGitLabAPI,
|
||||
createDebugAPI,
|
||||
createClaudeCodeAPI,
|
||||
createMcpAPI
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude",
|
||||
"version": "2.7.4",
|
||||
"version": "2.7.5",
|
||||
"description": "Autonomous multi-agent coding framework powered by Claude AI",
|
||||
"license": "AGPL-3.0",
|
||||
"author": "Auto Claude Team",
|
||||
|
||||
Reference in New Issue
Block a user