teleportation-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude/hooks/config-loader.mjs +93 -0
  2. package/.claude/hooks/heartbeat.mjs +331 -0
  3. package/.claude/hooks/notification.mjs +35 -0
  4. package/.claude/hooks/permission_request.mjs +307 -0
  5. package/.claude/hooks/post_tool_use.mjs +137 -0
  6. package/.claude/hooks/pre_tool_use.mjs +451 -0
  7. package/.claude/hooks/session-register.mjs +274 -0
  8. package/.claude/hooks/session_end.mjs +256 -0
  9. package/.claude/hooks/session_start.mjs +308 -0
  10. package/.claude/hooks/stop.mjs +277 -0
  11. package/.claude/hooks/user_prompt_submit.mjs +91 -0
  12. package/LICENSE +21 -0
  13. package/README.md +243 -0
  14. package/lib/auth/api-key.js +110 -0
  15. package/lib/auth/credentials.js +341 -0
  16. package/lib/backup/manager.js +461 -0
  17. package/lib/cli/daemon-commands.js +299 -0
  18. package/lib/cli/index.js +303 -0
  19. package/lib/cli/session-commands.js +294 -0
  20. package/lib/cli/snapshot-commands.js +223 -0
  21. package/lib/cli/worktree-commands.js +291 -0
  22. package/lib/config/manager.js +306 -0
  23. package/lib/daemon/lifecycle.js +336 -0
  24. package/lib/daemon/pid-manager.js +160 -0
  25. package/lib/daemon/teleportation-daemon.js +2009 -0
  26. package/lib/handoff/config.js +102 -0
  27. package/lib/handoff/example.js +152 -0
  28. package/lib/handoff/git-handoff.js +351 -0
  29. package/lib/handoff/handoff.js +277 -0
  30. package/lib/handoff/index.js +25 -0
  31. package/lib/handoff/session-state.js +238 -0
  32. package/lib/install/installer.js +555 -0
  33. package/lib/machine-coders/claude-code-adapter.js +329 -0
  34. package/lib/machine-coders/example.js +239 -0
  35. package/lib/machine-coders/gemini-cli-adapter.js +406 -0
  36. package/lib/machine-coders/index.js +103 -0
  37. package/lib/machine-coders/interface.js +168 -0
  38. package/lib/router/classifier.js +251 -0
  39. package/lib/router/example.js +92 -0
  40. package/lib/router/index.js +69 -0
  41. package/lib/router/mech-llms-client.js +277 -0
  42. package/lib/router/models.js +188 -0
  43. package/lib/router/router.js +382 -0
  44. package/lib/session/cleanup.js +100 -0
  45. package/lib/session/metadata.js +258 -0
  46. package/lib/session/mute-checker.js +114 -0
  47. package/lib/session-registry/manager.js +302 -0
  48. package/lib/snapshot/manager.js +390 -0
  49. package/lib/utils/errors.js +166 -0
  50. package/lib/utils/logger.js +148 -0
  51. package/lib/utils/retry.js +155 -0
  52. package/lib/worktree/manager.js +301 -0
  53. package/package.json +66 -0
  54. package/teleportation-cli.cjs +2987 -0
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session metadata extraction module
4
+ * Extracts project information, git status, and other context
5
+ */
6
+
7
+ import { execSync } from 'child_process';
8
+ import { basename, dirname, join } from 'path';
9
+ import { homedir, hostname, userInfo } from 'os';
10
+ import { stat, readFile } from 'fs/promises';
11
+
12
+ /**
13
+ * Extract project name from git repository or fall back to directory name
14
+ */
15
+ export async function getProjectName(cwd) {
16
+ try {
17
+ // Try to get git remote URL
18
+ const gitRemote = execSync('git config --get remote.origin.url', {
19
+ cwd,
20
+ encoding: 'utf8',
21
+ stdio: ['ignore', 'pipe', 'ignore']
22
+ }).trim();
23
+
24
+ if (gitRemote) {
25
+ // Extract repo name from URL (handles both SSH and HTTPS)
26
+ const match = gitRemote.match(/(?:.*\/)?([^\/]+?)(?:\.git)?$/);
27
+ if (match && match[1]) {
28
+ return match[1];
29
+ }
30
+ }
31
+ } catch (e) {
32
+ // Not a git repo or git command failed
33
+ }
34
+
35
+ // Fall back to directory name
36
+ return basename(cwd);
37
+ }
38
+
39
+ /**
40
+ * Get current git branch name
41
+ */
42
+ export function getCurrentBranch(cwd) {
43
+ try {
44
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
45
+ cwd,
46
+ encoding: 'utf8',
47
+ stdio: ['ignore', 'pipe', 'ignore']
48
+ }).trim();
49
+ return branch || null;
50
+ } catch (e) {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Get current git commit hash (short)
57
+ */
58
+ export function getCommitHash(cwd) {
59
+ try {
60
+ const hash = execSync('git rev-parse --short HEAD', {
61
+ cwd,
62
+ encoding: 'utf8',
63
+ stdio: ['ignore', 'pipe', 'ignore']
64
+ }).trim();
65
+ return hash || null;
66
+ } catch (e) {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Get last edited file from git status
73
+ */
74
+ export function getLastEditedFile(cwd) {
75
+ try {
76
+ // Get modified files from git status
77
+ const status = execSync('git status --porcelain', {
78
+ cwd,
79
+ encoding: 'utf8',
80
+ stdio: ['ignore', 'pipe', 'ignore']
81
+ }).trim();
82
+
83
+ if (status) {
84
+ // Get the first modified file
85
+ const lines = status.split('\n').filter(line => line.trim());
86
+ if (lines.length > 0) {
87
+ // Format: " M file.js" or "MM file.js" etc.
88
+ // Git status porcelain format: XY filename (X = index, Y = working tree)
89
+ // Format is exactly: "XY filename" where XY is 2 chars, then space, then filename
90
+ // Note: line may have leading space, so we need to handle both cases
91
+ const line = lines[0];
92
+ // Find the space after XY and take everything after it
93
+ // Match: optional leading space, then 2 chars (XY), then whitespace, then filename
94
+ const match = line.match(/^\s*.{2}\s+(.+)$/);
95
+ if (match && match[1]) {
96
+ return match[1].trim();
97
+ }
98
+ // Fallback: find first space after position 2 and take everything after it
99
+ const trimmed = line.trim();
100
+ if (trimmed.length > 2) {
101
+ const spaceIndex = trimmed.indexOf(' ', 2);
102
+ if (spaceIndex > 0 && spaceIndex < trimmed.length - 1) {
103
+ return trimmed.substring(spaceIndex + 1).trim();
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ // Try to get the most recently modified file from git log
110
+ const lastFile = execSync('git diff --name-only HEAD~1 HEAD 2>/dev/null || git ls-files -m | head -1', {
111
+ cwd,
112
+ encoding: 'utf8',
113
+ stdio: ['ignore', 'pipe', 'ignore'],
114
+ shell: true
115
+ }).trim();
116
+
117
+ return lastFile || null;
118
+ } catch (e) {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Get recent commit messages (last N commits)
125
+ */
126
+ export function getRecentCommits(cwd, count = 3) {
127
+ try {
128
+ const log = execSync(`git log -${count} --pretty=format:"%h|%s"`, {
129
+ cwd,
130
+ encoding: 'utf8',
131
+ stdio: ['ignore', 'pipe', 'ignore']
132
+ }).trim();
133
+
134
+ if (!log) return [];
135
+
136
+ return log.split('\n').map(line => {
137
+ const [hash, ...messageParts] = line.split('|');
138
+ return {
139
+ hash: hash || '',
140
+ message: messageParts.join('|') || ''
141
+ };
142
+ });
143
+ } catch (e) {
144
+ return [];
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Extract current task from recent commit messages
150
+ * Looks for patterns like "feat:", "fix:", "task:", etc.
151
+ */
152
+ export function getCurrentTask(cwd) {
153
+ try {
154
+ const commits = getRecentCommits(cwd, 1);
155
+ if (commits.length > 0 && commits[0].message) {
156
+ const message = commits[0].message;
157
+
158
+ // Try to extract task from commit message
159
+ // Patterns: "feat: description", "fix: description", "task: description"
160
+ const match = message.match(/^(feat|fix|task|chore|docs|refactor|test|perf|style):\s*(.+)/i);
161
+ if (match && match[2]) {
162
+ return match[2].trim();
163
+ }
164
+
165
+ // Return first line of commit message
166
+ return message.split('\n')[0].trim();
167
+ }
168
+ return null;
169
+ } catch (e) {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Check if directory is a git repository
176
+ */
177
+ export function isGitRepo(cwd) {
178
+ try {
179
+ execSync('git rev-parse --git-dir', {
180
+ cwd,
181
+ encoding: 'utf8',
182
+ stdio: ['ignore', 'pipe', 'ignore']
183
+ });
184
+ return true;
185
+ } catch (e) {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Get system information
192
+ */
193
+ export function getSystemInfo() {
194
+ return {
195
+ hostname: hostname(),
196
+ username: userInfo().username,
197
+ platform: process.platform,
198
+ nodeVersion: process.version
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Get current Claude model being used
204
+ * Checks in order: ANTHROPIC_MODEL env var > CLAUDE_MODEL env var > settings.json
205
+ */
206
+ export async function getCurrentModel() {
207
+ // Priority 1: Environment variables
208
+ const envModel = process.env.ANTHROPIC_MODEL || process.env.CLAUDE_MODEL;
209
+ if (envModel) {
210
+ return envModel;
211
+ }
212
+
213
+ // Priority 2: Read from ~/.claude/settings.json
214
+ try {
215
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
216
+ const settingsContent = await readFile(settingsPath, 'utf8');
217
+ const settings = JSON.parse(settingsContent);
218
+ if (settings.model) {
219
+ return settings.model;
220
+ }
221
+ } catch (e) {
222
+ // Settings file doesn't exist or doesn't have model - that's ok
223
+ }
224
+
225
+ // No explicit model configured - return null (will use session default)
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Extract all session metadata for a given working directory
231
+ */
232
+ export async function extractSessionMetadata(cwd) {
233
+ const systemInfo = getSystemInfo();
234
+ const isGit = isGitRepo(cwd);
235
+ const currentModel = await getCurrentModel();
236
+
237
+ const metadata = {
238
+ session_id: null, // Will be set by caller
239
+ project_name: await getProjectName(cwd),
240
+ working_directory: cwd,
241
+ last_file_edited: isGit ? getLastEditedFile(cwd) : null,
242
+ current_branch: isGit ? getCurrentBranch(cwd) : null,
243
+ commit_hash: isGit ? getCommitHash(cwd) : null,
244
+ recent_commits: isGit ? getRecentCommits(cwd, 3) : [],
245
+ current_task: isGit ? getCurrentTask(cwd) : null,
246
+ current_model: currentModel, // Claude model being used in this session
247
+ hostname: systemInfo.hostname,
248
+ username: systemInfo.username,
249
+ platform: systemInfo.platform,
250
+ node_version: systemInfo.nodeVersion,
251
+ is_git_repo: isGit
252
+ // Note: started_at is intentionally NOT set here - it should only be set once
253
+ // when the session is first created in the relay server, not on re-registration
254
+ };
255
+
256
+ return metadata;
257
+ }
258
+
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session mute status checker
4
+ * Checks if a session is muted and caches the result
5
+ */
6
+
7
+ // Simple in-memory cache for mute status
8
+ // Key: session_id, Value: { muted: boolean, timestamp: number }
9
+ const muteCache = new Map();
10
+ const CACHE_TTL = 60000; // 1 minute cache TTL
11
+
12
+ /**
13
+ * Check if a session is muted
14
+ * @param {string} sessionId - Session ID to check
15
+ * @param {string} relayApiUrl - Relay API URL
16
+ * @param {string} relayApiKey - Relay API key for authentication
17
+ * @returns {Promise<boolean>} - True if session is muted, false otherwise
18
+ */
19
+ export async function isSessionMuted(sessionId, relayApiUrl, relayApiKey) {
20
+ if (!sessionId || !relayApiUrl || !relayApiKey) {
21
+ return false; // Default to not muted if missing info
22
+ }
23
+
24
+ // Check cache first
25
+ const cached = muteCache.get(sessionId);
26
+ if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
27
+ return cached.muted;
28
+ }
29
+
30
+ // Fetch from API
31
+ const controller = new AbortController();
32
+ const timeoutId = setTimeout(() => controller.abort(), 5000); // 5-second timeout
33
+
34
+ try {
35
+ const response = await fetch(`${relayApiUrl}/api/sessions/${sessionId}`, {
36
+ headers: {
37
+ 'Authorization': `Bearer ${relayApiKey}`,
38
+ 'Content-Type': 'application/json'
39
+ },
40
+ signal: controller.signal
41
+ });
42
+
43
+ if (response.ok) {
44
+ const session = await response.json();
45
+ const muted = session.muted === true || session.meta?.muted === true;
46
+
47
+ // Cache the result
48
+ muteCache.set(sessionId, {
49
+ muted,
50
+ timestamp: Date.now()
51
+ });
52
+
53
+ return muted;
54
+ } else if (response.status === 404) {
55
+ // Session not found - default to not muted
56
+ return false;
57
+ } else {
58
+ // API error - use cached value if available, otherwise default to not muted
59
+ if (cached) {
60
+ return cached.muted;
61
+ }
62
+ return false;
63
+ }
64
+ } catch (error) {
65
+ // Network error or timeout - use cached value if available, otherwise default to not muted
66
+ if (error.name === 'AbortError') {
67
+ // Timeout occurred
68
+ if (cached) {
69
+ return cached.muted;
70
+ }
71
+ return false;
72
+ }
73
+ if (cached) {
74
+ return cached.muted;
75
+ }
76
+ // If no cache and API fails, default to not muted (fail open)
77
+ return false;
78
+ } finally {
79
+ // Always clear timeout to prevent memory leaks
80
+ clearTimeout(timeoutId);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Clear mute status cache for a session
86
+ * @param {string} sessionId - Session ID to clear from cache
87
+ */
88
+ export function clearMuteCache(sessionId) {
89
+ if (sessionId) {
90
+ muteCache.delete(sessionId);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Clear all mute status cache
96
+ */
97
+ export function clearAllMuteCache() {
98
+ muteCache.clear();
99
+ }
100
+
101
+ /**
102
+ * Get cache statistics (for debugging)
103
+ */
104
+ export function getCacheStats() {
105
+ return {
106
+ size: muteCache.size,
107
+ entries: Array.from(muteCache.entries()).map(([id, data]) => ({
108
+ sessionId: id,
109
+ muted: data.muted,
110
+ age: Date.now() - data.timestamp
111
+ }))
112
+ };
113
+ }
114
+
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session Registry Module
4
+ * Tracks active coding sessions, their worktrees, and potential conflicts
5
+ */
6
+
7
+ import { readFile, writeFile, mkdir } from 'fs/promises';
8
+ import { join } from 'path';
9
+ import { existsSync } from 'fs';
10
+ import { homedir } from 'os';
11
+ import { getRepoRoot } from '../worktree/manager.js';
12
+
13
+ const REGISTRY_DIR = join(homedir(), '.teleportation', 'session-registry');
14
+ const REGISTRY_FILE = join(REGISTRY_DIR, 'sessions.json');
15
+
16
+ /**
17
+ * Session entry structure
18
+ * @typedef {Object} SessionEntry
19
+ * @property {string} id - Session ID
20
+ * @property {string} agent - Agent type (claude-code, windsurf, cursor, etc.)
21
+ * @property {string} worktreePath - Path to worktree
22
+ * @property {string} branch - Branch name
23
+ * @property {string} repoRoot - Repository root path
24
+ * @property {number} startedAt - Timestamp when session started
25
+ * @property {number} lastActiveAt - Last activity timestamp
26
+ * @property {string} status - Session status (active, paused, completed)
27
+ * @property {Array<string>} modifiedFiles - List of modified files
28
+ */
29
+
30
+ /**
31
+ * Load the session registry
32
+ * @returns {Promise<Array<SessionEntry>>}
33
+ */
34
+ async function loadRegistry() {
35
+ await mkdir(REGISTRY_DIR, { recursive: true });
36
+
37
+ if (!existsSync(REGISTRY_FILE)) {
38
+ return [];
39
+ }
40
+
41
+ try {
42
+ const content = await readFile(REGISTRY_FILE, 'utf8');
43
+ return JSON.parse(content);
44
+ } catch (error) {
45
+ console.error(`Failed to load registry: ${error.message}`);
46
+ return [];
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Save the session registry
52
+ * @param {Array<SessionEntry>} sessions
53
+ * @returns {Promise<void>}
54
+ */
55
+ async function saveRegistry(sessions) {
56
+ await mkdir(REGISTRY_DIR, { recursive: true, mode: 0o700 });
57
+ await writeFile(REGISTRY_FILE, JSON.stringify(sessions, null, 2), { mode: 0o600 });
58
+ }
59
+
60
+ /**
61
+ * Register a new session
62
+ * @param {string} sessionId - Unique session identifier
63
+ * @param {string} agent - Agent type
64
+ * @param {string} worktreePath - Path to worktree
65
+ * @param {string} branch - Branch name
66
+ * @returns {Promise<SessionEntry>}
67
+ */
68
+ export async function registerSession(sessionId, agent, worktreePath, branch) {
69
+ const sessions = await loadRegistry();
70
+
71
+ // Check if session already exists
72
+ const existing = sessions.find(s => s.id === sessionId);
73
+ if (existing) {
74
+ throw new Error(`Session ${sessionId} is already registered`);
75
+ }
76
+
77
+ const repoRoot = getRepoRoot();
78
+ const session = {
79
+ id: sessionId,
80
+ agent,
81
+ worktreePath,
82
+ branch,
83
+ repoRoot,
84
+ startedAt: Date.now(),
85
+ lastActiveAt: Date.now(),
86
+ status: 'active',
87
+ modifiedFiles: []
88
+ };
89
+
90
+ sessions.push(session);
91
+ await saveRegistry(sessions);
92
+
93
+ return session;
94
+ }
95
+
96
+ /**
97
+ * Update session activity
98
+ * @param {string} sessionId - Session identifier
99
+ * @param {Array<string>} modifiedFiles - Optional list of modified files
100
+ * @returns {Promise<void>}
101
+ */
102
+ export async function updateSessionActivity(sessionId, modifiedFiles = null) {
103
+ const sessions = await loadRegistry();
104
+ const session = sessions.find(s => s.id === sessionId);
105
+
106
+ if (!session) {
107
+ throw new Error(`Session ${sessionId} not found`);
108
+ }
109
+
110
+ session.lastActiveAt = Date.now();
111
+ if (modifiedFiles !== null) {
112
+ session.modifiedFiles = modifiedFiles;
113
+ }
114
+
115
+ await saveRegistry(sessions);
116
+ }
117
+
118
+ /**
119
+ * Mark session as completed
120
+ * @param {string} sessionId - Session identifier
121
+ * @returns {Promise<void>}
122
+ */
123
+ export async function completeSession(sessionId) {
124
+ const sessions = await loadRegistry();
125
+ const session = sessions.find(s => s.id === sessionId);
126
+
127
+ if (!session) {
128
+ throw new Error(`Session ${sessionId} not found`);
129
+ }
130
+
131
+ session.status = 'completed';
132
+ session.lastActiveAt = Date.now();
133
+
134
+ await saveRegistry(sessions);
135
+ }
136
+
137
+ /**
138
+ * Pause a session
139
+ * @param {string} sessionId - Session identifier
140
+ * @returns {Promise<void>}
141
+ */
142
+ export async function pauseSession(sessionId) {
143
+ const sessions = await loadRegistry();
144
+ const session = sessions.find(s => s.id === sessionId);
145
+
146
+ if (!session) {
147
+ throw new Error(`Session ${sessionId} not found`);
148
+ }
149
+
150
+ session.status = 'paused';
151
+ session.lastActiveAt = Date.now();
152
+
153
+ await saveRegistry(sessions);
154
+ }
155
+
156
+ /**
157
+ * Resume a paused session
158
+ * @param {string} sessionId - Session identifier
159
+ * @returns {Promise<void>}
160
+ */
161
+ export async function resumeSession(sessionId) {
162
+ const sessions = await loadRegistry();
163
+ const session = sessions.find(s => s.id === sessionId);
164
+
165
+ if (!session) {
166
+ throw new Error(`Session ${sessionId} not found`);
167
+ }
168
+
169
+ session.status = 'active';
170
+ session.lastActiveAt = Date.now();
171
+
172
+ await saveRegistry(sessions);
173
+ }
174
+
175
+ /**
176
+ * Remove a session from the registry
177
+ * @param {string} sessionId - Session identifier
178
+ * @returns {Promise<void>}
179
+ */
180
+ export async function unregisterSession(sessionId) {
181
+ const sessions = await loadRegistry();
182
+ const filtered = sessions.filter(s => s.id !== sessionId);
183
+
184
+ if (filtered.length === sessions.length) {
185
+ throw new Error(`Session ${sessionId} not found`);
186
+ }
187
+
188
+ await saveRegistry(filtered);
189
+ }
190
+
191
+ /**
192
+ * List all sessions
193
+ * @param {string} status - Optional status filter
194
+ * @returns {Promise<Array<SessionEntry>>}
195
+ */
196
+ export async function listSessions(status = null) {
197
+ const sessions = await loadRegistry();
198
+
199
+ if (status) {
200
+ return sessions.filter(s => s.status === status);
201
+ }
202
+
203
+ return sessions;
204
+ }
205
+
206
+ /**
207
+ * Get session by ID
208
+ * @param {string} sessionId - Session identifier
209
+ * @returns {Promise<SessionEntry|null>}
210
+ */
211
+ export async function getSession(sessionId) {
212
+ const sessions = await loadRegistry();
213
+ return sessions.find(s => s.id === sessionId) || null;
214
+ }
215
+
216
+ /**
217
+ * Get active sessions in the current repository
218
+ * @returns {Promise<Array<SessionEntry>>}
219
+ */
220
+ export async function getActiveSessionsInRepo() {
221
+ const repoRoot = getRepoRoot();
222
+ const sessions = await loadRegistry();
223
+
224
+ return sessions.filter(s => s.repoRoot === repoRoot && s.status === 'active');
225
+ }
226
+
227
+ /**
228
+ * Detect potential conflicts between sessions
229
+ * @param {string} sessionId - Session identifier to check
230
+ * @returns {Promise<Array<{sessionId: string, conflictingFiles: Array<string>}>>}
231
+ */
232
+ export async function detectConflicts(sessionId) {
233
+ const session = await getSession(sessionId);
234
+ if (!session) {
235
+ throw new Error(`Session ${sessionId} not found`);
236
+ }
237
+
238
+ const activeSessions = await getActiveSessionsInRepo();
239
+ const conflicts = [];
240
+
241
+ for (const other of activeSessions) {
242
+ if (other.id === sessionId) {
243
+ continue;
244
+ }
245
+
246
+ // Check for overlapping modified files
247
+ const conflictingFiles = session.modifiedFiles.filter(file =>
248
+ other.modifiedFiles.includes(file)
249
+ );
250
+
251
+ if (conflictingFiles.length > 0) {
252
+ conflicts.push({
253
+ sessionId: other.id,
254
+ agent: other.agent,
255
+ branch: other.branch,
256
+ conflictingFiles
257
+ });
258
+ }
259
+ }
260
+
261
+ return conflicts;
262
+ }
263
+
264
+ /**
265
+ * Clean up stale sessions (inactive for > 24 hours)
266
+ * @returns {Promise<number>} Number of sessions cleaned up
267
+ */
268
+ export async function cleanupStaleSessions() {
269
+ const sessions = await loadRegistry();
270
+ const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
271
+
272
+ const active = sessions.filter(s => {
273
+ // Keep completed sessions and recently active ones
274
+ if (s.status === 'completed') {
275
+ return s.lastActiveAt > oneDayAgo;
276
+ }
277
+ return s.lastActiveAt > oneDayAgo;
278
+ });
279
+
280
+ const removed = sessions.length - active.length;
281
+
282
+ if (removed > 0) {
283
+ await saveRegistry(active);
284
+ }
285
+
286
+ return removed;
287
+ }
288
+
289
+ /**
290
+ * Get session statistics
291
+ * @returns {Promise<{total: number, active: number, paused: number, completed: number}>}
292
+ */
293
+ export async function getSessionStats() {
294
+ const sessions = await loadRegistry();
295
+
296
+ return {
297
+ total: sessions.length,
298
+ active: sessions.filter(s => s.status === 'active').length,
299
+ paused: sessions.filter(s => s.status === 'paused').length,
300
+ completed: sessions.filter(s => s.status === 'completed').length
301
+ };
302
+ }