feat: add TeamBackupService for team data persistence
- Auto-backup team files (config, tasks, inboxes, attachments, etc.) to app's own storage (Electron userData) every 3 minutes - Sync backup on app shutdown (after SIGKILL, before cleanup) - Auto-restore missing/corrupted teams on startup with identity guard - Move attachments to app-owned storage (outside ~/.claude/) - Add stopAllTeams() with SIGKILL for reliable shutdown - Clean up app data when permanently deleting teams - Generation-based shutdown isolation prevents async/sync races
This commit is contained in:
parent
35da03c25f
commit
c30184052a
9 changed files with 1139 additions and 24 deletions
|
|
@ -23,6 +23,7 @@ import { ChangeExtractorService } from '@main/services/team/ChangeExtractorServi
|
||||||
import { FileContentResolver } from '@main/services/team/FileContentResolver';
|
import { FileContentResolver } from '@main/services/team/FileContentResolver';
|
||||||
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
|
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
|
||||||
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
|
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
|
||||||
|
import { TeamBackupService } from '@main/services/team/TeamBackupService';
|
||||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||||
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
||||||
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
||||||
|
|
@ -338,6 +339,7 @@ let cliInstallerService: CliInstallerService;
|
||||||
let ptyTerminalService: PtyTerminalService;
|
let ptyTerminalService: PtyTerminalService;
|
||||||
let httpServer: HttpServer;
|
let httpServer: HttpServer;
|
||||||
let schedulerService: SchedulerService;
|
let schedulerService: SchedulerService;
|
||||||
|
let teamBackupService: TeamBackupService | null = null;
|
||||||
|
|
||||||
// File watcher event cleanup functions
|
// File watcher event cleanup functions
|
||||||
let fileChangeCleanup: (() => void) | null = null;
|
let fileChangeCleanup: (() => void) | null = null;
|
||||||
|
|
@ -528,6 +530,16 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${String(e)}`
|
`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${String(e)}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Schedule debounced backup for changed task file
|
||||||
|
if (teamBackupService) {
|
||||||
|
teamBackupService.scheduleTaskBackup(teamName, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup on config changes (covers team ready, config updates)
|
||||||
|
if (row.type === 'config' && detail === 'config.json' && teamBackupService) {
|
||||||
|
void teamBackupService.backupTeam(teamName).catch(() => undefined);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|
@ -671,6 +683,13 @@ function initializeServices(): void {
|
||||||
ptyTerminalService = new PtyTerminalService();
|
ptyTerminalService = new PtyTerminalService();
|
||||||
teamDataService = new TeamDataService();
|
teamDataService = new TeamDataService();
|
||||||
teamProvisioningService = new TeamProvisioningService();
|
teamProvisioningService = new TeamProvisioningService();
|
||||||
|
teamBackupService = new TeamBackupService();
|
||||||
|
// Fire-and-forget: initializeServices() is sync, cannot await.
|
||||||
|
// Safe because TeamBackupService.initialized flag blocks all backup/restore
|
||||||
|
// operations until initialize() completes internally (restore → prune → set flag).
|
||||||
|
void teamBackupService.initialize().catch((error: unknown) =>
|
||||||
|
logger.warn(`[Init] TeamBackupService init failed: ${String(error)}`)
|
||||||
|
);
|
||||||
|
|
||||||
// Cross-team communication service
|
// Cross-team communication service
|
||||||
const crossTeamConfigReader = new TeamConfigReader();
|
const crossTeamConfigReader = new TeamConfigReader();
|
||||||
|
|
@ -783,7 +802,8 @@ function initializeServices(): void {
|
||||||
pluginInstallService,
|
pluginInstallService,
|
||||||
mcpInstallService,
|
mcpInstallService,
|
||||||
apiKeyService,
|
apiKeyService,
|
||||||
crossTeamService
|
crossTeamService,
|
||||||
|
teamBackupService ?? undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||||
|
|
@ -848,6 +868,16 @@ async function startHttpServer(
|
||||||
function shutdownServices(): void {
|
function shutdownServices(): void {
|
||||||
logger.info('Shutting down services...');
|
logger.info('Shutting down services...');
|
||||||
|
|
||||||
|
// 1. Kill all team CLI processes via SIGKILL BEFORE anything else.
|
||||||
|
if (teamProvisioningService) {
|
||||||
|
teamProvisioningService.stopAllTeams();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sync backup all team data (files are stable after SIGKILL).
|
||||||
|
if (teamBackupService) {
|
||||||
|
teamBackupService.runShutdownBackupSync();
|
||||||
|
}
|
||||||
|
|
||||||
// Stop HTTP server
|
// Stop HTTP server
|
||||||
if (httpServer?.isRunning()) {
|
if (httpServer?.isRunning()) {
|
||||||
void httpServer.stop();
|
void httpServer.stop();
|
||||||
|
|
@ -880,13 +910,6 @@ function shutdownServices(): void {
|
||||||
sshConnectionManager.dispose();
|
sshConnectionManager.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop all running team provisioning processes
|
|
||||||
if (teamProvisioningService) {
|
|
||||||
for (const teamName of teamProvisioningService.getAliveTeams()) {
|
|
||||||
teamProvisioningService.stopTeam(teamName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop background polling timers (prevents hanging shutdown).
|
// Stop background polling timers (prevents hanging shutdown).
|
||||||
if (teamDataService) {
|
if (teamDataService) {
|
||||||
teamDataService.stopProcessHealthPolling();
|
teamDataService.stopProcessHealthPolling();
|
||||||
|
|
@ -905,6 +928,9 @@ function shutdownServices(): void {
|
||||||
// Remove IPC handlers
|
// Remove IPC handlers
|
||||||
removeIpcHandlers();
|
removeIpcHandlers();
|
||||||
|
|
||||||
|
// Dispose backup service timers
|
||||||
|
teamBackupService?.dispose();
|
||||||
|
|
||||||
logger.info('Services shut down successfully');
|
logger.info('Services shut down successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ import type {
|
||||||
} from '../services';
|
} from '../services';
|
||||||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||||
|
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||||
|
|
@ -141,7 +142,8 @@ export function initializeIpcHandlers(
|
||||||
pluginInstaller?: PluginInstallService,
|
pluginInstaller?: PluginInstallService,
|
||||||
mcpInstaller?: McpInstallService,
|
mcpInstaller?: McpInstallService,
|
||||||
apiKeyService?: ApiKeyService,
|
apiKeyService?: ApiKeyService,
|
||||||
crossTeamService?: CrossTeamService
|
crossTeamService?: CrossTeamService,
|
||||||
|
teamBackupService?: TeamBackupService
|
||||||
): void {
|
): void {
|
||||||
// Initialize domain handlers with registry
|
// Initialize domain handlers with registry
|
||||||
initializeProjectHandlers(registry);
|
initializeProjectHandlers(registry);
|
||||||
|
|
@ -155,7 +157,8 @@ export function initializeIpcHandlers(
|
||||||
teamDataService,
|
teamDataService,
|
||||||
teamProvisioningService,
|
teamProvisioningService,
|
||||||
teamMemberLogsFinder,
|
teamMemberLogsFinder,
|
||||||
memberStatsComputer
|
memberStatsComputer,
|
||||||
|
teamBackupService
|
||||||
);
|
);
|
||||||
initializeConfigHandlers({
|
initializeConfigHandlers({
|
||||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
|
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||||
import {
|
import {
|
||||||
TEAM_ADD_MEMBER,
|
TEAM_ADD_MEMBER,
|
||||||
|
|
@ -97,6 +98,7 @@ import type {
|
||||||
TeamMemberLogsFinder,
|
TeamMemberLogsFinder,
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
|
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||||||
import type {
|
import type {
|
||||||
AgentActionMode,
|
AgentActionMode,
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
|
|
@ -193,6 +195,7 @@ let teamDataService: TeamDataService | null = null;
|
||||||
let teamProvisioningService: TeamProvisioningService | null = null;
|
let teamProvisioningService: TeamProvisioningService | null = null;
|
||||||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||||
let memberStatsComputer: MemberStatsComputer | null = null;
|
let memberStatsComputer: MemberStatsComputer | null = null;
|
||||||
|
let teamBackupService: TeamBackupService | null = null;
|
||||||
|
|
||||||
const attachmentStore = new TeamAttachmentStore();
|
const attachmentStore = new TeamAttachmentStore();
|
||||||
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||||
|
|
@ -206,12 +209,14 @@ export function initializeTeamHandlers(
|
||||||
service: TeamDataService,
|
service: TeamDataService,
|
||||||
provisioningService: TeamProvisioningService,
|
provisioningService: TeamProvisioningService,
|
||||||
logsFinder?: TeamMemberLogsFinder,
|
logsFinder?: TeamMemberLogsFinder,
|
||||||
statsComputer?: MemberStatsComputer
|
statsComputer?: MemberStatsComputer,
|
||||||
|
backupService?: TeamBackupService
|
||||||
): void {
|
): void {
|
||||||
teamDataService = service;
|
teamDataService = service;
|
||||||
teamProvisioningService = provisioningService;
|
teamProvisioningService = provisioningService;
|
||||||
teamMemberLogsFinder = logsFinder ?? null;
|
teamMemberLogsFinder = logsFinder ?? null;
|
||||||
memberStatsComputer = statsComputer ?? null;
|
memberStatsComputer = statsComputer ?? null;
|
||||||
|
teamBackupService = backupService ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
|
|
@ -521,9 +526,21 @@ async function handlePermanentlyDeleteTeam(
|
||||||
if (!validated.valid) {
|
if (!validated.valid) {
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('permanentlyDeleteTeam', () =>
|
return wrapTeamHandler('permanentlyDeleteTeam', async () => {
|
||||||
getTeamDataService().permanentlyDeleteTeam(validated.value!)
|
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
||||||
);
|
// Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/
|
||||||
|
const appData = getAppDataPath();
|
||||||
|
await fs.promises
|
||||||
|
.rm(path.join(appData, 'attachments', validated.value!), { recursive: true, force: true })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await fs.promises
|
||||||
|
.rm(path.join(appData, 'task-attachments', validated.value!), { recursive: true, force: true })
|
||||||
|
.catch(() => undefined);
|
||||||
|
// Mark in backup registry AFTER successful deletion
|
||||||
|
if (teamBackupService) {
|
||||||
|
await teamBackupService.markDeletedByUser(validated.value!);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateConfig(
|
async function handleUpdateConfig(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -9,7 +9,6 @@ import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamAttachmentStore');
|
const logger = createLogger('Service:TeamAttachmentStore');
|
||||||
|
|
||||||
const ATTACHMENTS_DIR = 'attachments';
|
|
||||||
const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap
|
const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap
|
||||||
|
|
||||||
export class TeamAttachmentStore {
|
export class TeamAttachmentStore {
|
||||||
|
|
@ -27,7 +26,7 @@ export class TeamAttachmentStore {
|
||||||
|
|
||||||
private getDir(teamName: string): string {
|
private getDir(teamName: string): string {
|
||||||
this.assertSafePathSegment('teamName', teamName);
|
this.assertSafePathSegment('teamName', teamName);
|
||||||
return path.join(getTeamsBasePath(), teamName, ATTACHMENTS_DIR);
|
return path.join(getAppDataPath(), 'attachments', teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFilePath(teamName: string, messageId: string): string {
|
private getFilePath(teamName: string, messageId: string): string {
|
||||||
|
|
|
||||||
1016
src/main/services/team/TeamBackupService.ts
Normal file
1016
src/main/services/team/TeamBackupService.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -3708,7 +3708,7 @@ export class TeamProvisioningService {
|
||||||
/**
|
/**
|
||||||
* Stop the running process for a team. No-op if team is not running.
|
* Stop the running process for a team. No-op if team is not running.
|
||||||
*/
|
*/
|
||||||
stopTeam(teamName: string): void {
|
stopTeam(teamName: string, signal?: NodeJS.Signals): void {
|
||||||
const runId = this.activeByTeam.get(teamName);
|
const runId = this.activeByTeam.get(teamName);
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -3723,12 +3723,26 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
run.processKilled = true;
|
run.processKilled = true;
|
||||||
run.cancelRequested = true;
|
run.cancelRequested = true;
|
||||||
run.child?.stdin?.end();
|
// Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful
|
||||||
killProcessTree(run.child);
|
// shutdown which deletes team files (config.json, inboxes/, tasks/).
|
||||||
|
killProcessTree(run.child, signal);
|
||||||
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
|
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
logger.info(`[${teamName}] Process stopped by user`);
|
logger.info(`[${teamName}] Process stopped (signal=${signal ?? 'SIGTERM'})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all running team processes. Called during app shutdown.
|
||||||
|
* Uses SIGKILL (uncatchable) to guarantee instant death without cleanup.
|
||||||
|
*/
|
||||||
|
stopAllTeams(): void {
|
||||||
|
const alive = this.getAliveTeams();
|
||||||
|
if (alive.length === 0) return;
|
||||||
|
logger.info(`Killing all team processes on shutdown (SIGKILL): ${alive.join(', ')}`);
|
||||||
|
for (const teamName of alive) {
|
||||||
|
this.stopTeam(teamName, 'SIGKILL');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -7,7 +7,6 @@ import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamTaskAttachmentStore');
|
const logger = createLogger('Service:TeamTaskAttachmentStore');
|
||||||
|
|
||||||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
|
||||||
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
|
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||||
|
|
||||||
export class TeamTaskAttachmentStore {
|
export class TeamTaskAttachmentStore {
|
||||||
|
|
@ -30,7 +29,7 @@ export class TeamTaskAttachmentStore {
|
||||||
private getTaskDir(teamName: string, taskId: string): string {
|
private getTaskDir(teamName: string, taskId: string): string {
|
||||||
this.assertSafePathSegment('teamName', teamName);
|
this.assertSafePathSegment('teamName', teamName);
|
||||||
this.assertSafePathSegment('taskId', taskId);
|
this.assertSafePathSegment('taskId', taskId);
|
||||||
return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId);
|
return path.join(getAppDataPath(), 'task-attachments', teamName, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeStoredFilename(original: string): string {
|
private sanitizeStoredFilename(original: string): string {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export { MemberStatsComputer } from './MemberStatsComputer';
|
||||||
export { ReviewApplierService } from './ReviewApplierService';
|
export { ReviewApplierService } from './ReviewApplierService';
|
||||||
export { TaskBoundaryParser } from './TaskBoundaryParser';
|
export { TaskBoundaryParser } from './TaskBoundaryParser';
|
||||||
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
||||||
|
export { TeamBackupService } from './TeamBackupService';
|
||||||
export { TeamConfigReader } from './TeamConfigReader';
|
export { TeamConfigReader } from './TeamConfigReader';
|
||||||
export { TeamDataService } from './TeamDataService';
|
export { TeamDataService } from './TeamDataService';
|
||||||
export { TeamInboxReader } from './TeamInboxReader';
|
export { TeamInboxReader } from './TeamInboxReader';
|
||||||
|
|
|
||||||
|
|
@ -368,3 +368,43 @@ export function getToolsBasePath(): string {
|
||||||
export function getSchedulesBasePath(): string {
|
export function getSchedulesBasePath(): string {
|
||||||
return path.join(getClaudeBasePath(), 'claude-devtools-schedules');
|
return path.join(getClaudeBasePath(), 'claude-devtools-schedules');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the backups directory path for the app's own storage.
|
||||||
|
* Uses Electron's app.getPath('userData') for cross-platform correctness:
|
||||||
|
* macOS: ~/Library/Application Support/Claude Agent Teams UI/backups
|
||||||
|
* Linux: ~/.config/Claude Agent Teams UI/backups
|
||||||
|
* Windows: C:\Users\Name\AppData\Roaming\Claude Agent Teams UI\backups
|
||||||
|
*/
|
||||||
|
export function getBackupsBasePath(): string {
|
||||||
|
return path.join(getAppDataBasePath(), 'backups');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the app's own data directory (attachments, task-attachments).
|
||||||
|
* Separate from ~/.claude/ so CLI cannot delete our data.
|
||||||
|
*/
|
||||||
|
export function getAppDataPath(): string {
|
||||||
|
return path.join(getAppDataBasePath(), 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App data root (Electron userData) ──
|
||||||
|
|
||||||
|
let appDataBasePathOverride: string | null = null;
|
||||||
|
|
||||||
|
export function setAppDataBasePath(p: string): void {
|
||||||
|
appDataBasePathOverride = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppDataBasePath(): string {
|
||||||
|
if (appDataBasePathOverride) return appDataBasePathOverride;
|
||||||
|
// Fallback: resolve lazily from Electron app (safe after app.whenReady)
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const { app } = require('electron') as typeof import('electron');
|
||||||
|
return app.getPath('userData');
|
||||||
|
} catch {
|
||||||
|
// Outside Electron (tests, CLI) — fall back to home dir
|
||||||
|
return path.join(getHomeDir(), '.claude-agent-teams-ui');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue