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.
- package/.claude/hooks/config-loader.mjs +93 -0
- package/.claude/hooks/heartbeat.mjs +331 -0
- package/.claude/hooks/notification.mjs +35 -0
- package/.claude/hooks/permission_request.mjs +307 -0
- package/.claude/hooks/post_tool_use.mjs +137 -0
- package/.claude/hooks/pre_tool_use.mjs +451 -0
- package/.claude/hooks/session-register.mjs +274 -0
- package/.claude/hooks/session_end.mjs +256 -0
- package/.claude/hooks/session_start.mjs +308 -0
- package/.claude/hooks/stop.mjs +277 -0
- package/.claude/hooks/user_prompt_submit.mjs +91 -0
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/lib/auth/api-key.js +110 -0
- package/lib/auth/credentials.js +341 -0
- package/lib/backup/manager.js +461 -0
- package/lib/cli/daemon-commands.js +299 -0
- package/lib/cli/index.js +303 -0
- package/lib/cli/session-commands.js +294 -0
- package/lib/cli/snapshot-commands.js +223 -0
- package/lib/cli/worktree-commands.js +291 -0
- package/lib/config/manager.js +306 -0
- package/lib/daemon/lifecycle.js +336 -0
- package/lib/daemon/pid-manager.js +160 -0
- package/lib/daemon/teleportation-daemon.js +2009 -0
- package/lib/handoff/config.js +102 -0
- package/lib/handoff/example.js +152 -0
- package/lib/handoff/git-handoff.js +351 -0
- package/lib/handoff/handoff.js +277 -0
- package/lib/handoff/index.js +25 -0
- package/lib/handoff/session-state.js +238 -0
- package/lib/install/installer.js +555 -0
- package/lib/machine-coders/claude-code-adapter.js +329 -0
- package/lib/machine-coders/example.js +239 -0
- package/lib/machine-coders/gemini-cli-adapter.js +406 -0
- package/lib/machine-coders/index.js +103 -0
- package/lib/machine-coders/interface.js +168 -0
- package/lib/router/classifier.js +251 -0
- package/lib/router/example.js +92 -0
- package/lib/router/index.js +69 -0
- package/lib/router/mech-llms-client.js +277 -0
- package/lib/router/models.js +188 -0
- package/lib/router/router.js +382 -0
- package/lib/session/cleanup.js +100 -0
- package/lib/session/metadata.js +258 -0
- package/lib/session/mute-checker.js +114 -0
- package/lib/session-registry/manager.js +302 -0
- package/lib/snapshot/manager.js +390 -0
- package/lib/utils/errors.js +166 -0
- package/lib/utils/logger.js +148 -0
- package/lib/utils/retry.js +155 -0
- package/lib/worktree/manager.js +301 -0
- package/package.json +66 -0
- 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
|
+
}
|