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,274 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Session Registration Helper
4
+ * Registers a session with the relay API if not already registered.
5
+ * This is called lazily when an approval is created or a message is sent.
6
+ */
7
+
8
+ import { env } from 'node:process';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, join } from 'path';
11
+ import { readFile, writeFile } from 'node:fs/promises';
12
+ import { homedir, tmpdir } from 'node:os';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+
17
+ /**
18
+ * Load version info from ~/.teleportation/version.json
19
+ * Returns null if file doesn't exist (old installation)
20
+ */
21
+ async function loadVersionInfo() {
22
+ const versionFile = join(homedir(), '.teleportation', 'version.json');
23
+ try {
24
+ const content = await readFile(versionFile, 'utf8');
25
+ return JSON.parse(content);
26
+ } catch (e) {
27
+ // Version file doesn't exist - old installation
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Register a session with the relay API if not already registered
34
+ * @param {string} session_id - Session ID
35
+ * @param {string} cwd - Current working directory
36
+ * @param {object} config - Config object with relayApiUrl and relayApiKey
37
+ * @returns {Promise<boolean>} - True if registered successfully
38
+ */
39
+ export async function ensureSessionRegistered(session_id, cwd, config) {
40
+ const RELAY_API_URL = config.relayApiUrl || '';
41
+ const RELAY_API_KEY = config.relayApiKey || '';
42
+
43
+ if (!session_id || !RELAY_API_URL || !RELAY_API_KEY) {
44
+ if (env.DEBUG) {
45
+ console.error(`[SessionRegister] Early return: session_id=${!!session_id}, RELAY_API_URL=${!!RELAY_API_URL}, RELAY_API_KEY=${!!RELAY_API_KEY}`);
46
+ }
47
+ return false;
48
+ }
49
+
50
+ // Check if session is already registered (marker file exists)
51
+ // If so, skip API call - heartbeat keeps the session alive
52
+ const registrationMarker = join(tmpdir(), `teleportation-session-${session_id}.registered`);
53
+ try {
54
+ await readFile(registrationMarker);
55
+ // Marker exists - session already registered, skip API call
56
+ if (env.DEBUG) {
57
+ console.error(`[SessionRegister] Session ${session_id} already registered, skipping`);
58
+ }
59
+ return true;
60
+ } catch (e) {
61
+ // Marker doesn't exist - proceed with registration
62
+ }
63
+
64
+ if (env.DEBUG) {
65
+ console.error(`[SessionRegister] Registering session ${session_id}`);
66
+ }
67
+
68
+ // Extract enhanced session metadata
69
+ let metadata = { cwd };
70
+ try {
71
+ // Try to load metadata extraction module
72
+ const possiblePaths = [
73
+ join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
74
+ join(process.env.HOME || process.env.USERPROFILE || '', '.teleportation', 'lib', 'session', 'metadata.js'),
75
+ './lib/session/metadata.js'
76
+ ];
77
+
78
+ let metadataModule = null;
79
+ for (const path of possiblePaths) {
80
+ try {
81
+ metadataModule = await import('file://' + path);
82
+ break;
83
+ } catch (e) {
84
+ // Try next path
85
+ }
86
+ }
87
+
88
+ if (metadataModule && metadataModule.extractSessionMetadata && cwd) {
89
+ const extracted = await metadataModule.extractSessionMetadata(cwd);
90
+ extracted.session_id = session_id;
91
+ metadata = extracted;
92
+ }
93
+ } catch (e) {
94
+ // If metadata extraction fails, fall back to basic metadata
95
+ if (env.DEBUG) {
96
+ console.error('[SessionRegister] Failed to extract metadata:', e.message);
97
+ }
98
+ }
99
+
100
+ // Add version info to metadata
101
+ try {
102
+ const versionInfo = await loadVersionInfo();
103
+ if (versionInfo) {
104
+ metadata.teleportation_version = versionInfo.version;
105
+ metadata.protocol_version = versionInfo.protocol_version;
106
+ }
107
+ } catch (e) {
108
+ // Version info not available - old installation
109
+ }
110
+
111
+ try {
112
+ if (env.DEBUG) {
113
+ console.error(`[SessionRegister] Calling ${RELAY_API_URL}/api/sessions/register`);
114
+ }
115
+ const response = await fetch(`${RELAY_API_URL}/api/sessions/register`, {
116
+ method: 'POST',
117
+ headers: {
118
+ 'Content-Type': 'application/json',
119
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
120
+ },
121
+ body: JSON.stringify({ session_id, meta: metadata })
122
+ });
123
+
124
+ if (response.ok || response.status === 200) {
125
+ // Create marker file so we don't re-register on every tool use
126
+ try {
127
+ await writeFile(registrationMarker, JSON.stringify({
128
+ session_id,
129
+ registered_at: Date.now()
130
+ }), { mode: 0o600 });
131
+ } catch (e) {
132
+ // Ignore marker write failures - registration still succeeded
133
+ }
134
+
135
+ // Start heartbeat if not already running
136
+ try {
137
+ const heartbeatEnabled = config.session?.heartbeat?.enabled !== false;
138
+ const heartbeatInterval = config.session?.heartbeat?.interval || 120000;
139
+ const startDelay = config.session?.heartbeat?.startDelay || 5000;
140
+ const maxFailures = config.session?.heartbeat?.maxFailures || 3;
141
+
142
+ if (heartbeatEnabled && RELAY_API_URL && RELAY_API_KEY) {
143
+ const { spawn } = await import('child_process');
144
+ const heartbeatPath = join(__dirname, 'heartbeat.mjs');
145
+
146
+ // Check if heartbeat is already running for this session
147
+ const { tmpdir } = await import('os');
148
+ const { readFile } = await import('fs/promises');
149
+ const pidFile = join(tmpdir(), `teleportation-heartbeat-${session_id}.pid`);
150
+
151
+ let shouldStartHeartbeat = true;
152
+ try {
153
+ await readFile(pidFile);
154
+ // PID file exists, heartbeat might be running
155
+ shouldStartHeartbeat = false;
156
+ } catch (e) {
157
+ // PID file doesn't exist, start heartbeat
158
+ }
159
+
160
+ if (shouldStartHeartbeat) {
161
+ const heartbeat = spawn('node', [heartbeatPath, session_id], {
162
+ detached: true,
163
+ stdio: 'ignore',
164
+ env: {
165
+ ...process.env,
166
+ SESSION_ID: session_id,
167
+ RELAY_API_URL,
168
+ RELAY_API_KEY,
169
+ HEARTBEAT_INTERVAL: String(heartbeatInterval),
170
+ START_DELAY: String(startDelay),
171
+ MAX_FAILURES: String(maxFailures),
172
+ TELEPORTATION_DEBUG: process.env.TELEPORTATION_DEBUG || 'false'
173
+ }
174
+ });
175
+ heartbeat.unref();
176
+ }
177
+ }
178
+ } catch (error) {
179
+ // Don't fail registration if heartbeat spawn fails
180
+ if (env.DEBUG) {
181
+ console.error('[SessionRegister] Failed to spawn heartbeat:', error.message);
182
+ }
183
+ }
184
+
185
+ return true;
186
+ }
187
+ } catch (e) {
188
+ // Registration failed - this is okay, we'll try again next time
189
+ if (env.DEBUG) {
190
+ console.error('[SessionRegister] Failed to register session:', e.message);
191
+ }
192
+ }
193
+
194
+ return false;
195
+ }
196
+
197
+ /**
198
+ * Update session metadata without triggering a registration event
199
+ * Use this for updating dynamic metadata like current_branch, last_file_edited, etc.
200
+ * @param {string} session_id - Session ID
201
+ * @param {string} cwd - Current working directory
202
+ * @param {object} config - Config object with relayApiUrl and relayApiKey
203
+ * @returns {Promise<boolean>} - True if updated successfully
204
+ */
205
+ export async function updateSessionMetadata(session_id, cwd, config) {
206
+ const RELAY_API_URL = config.relayApiUrl || '';
207
+ const RELAY_API_KEY = config.relayApiKey || '';
208
+
209
+ if (!session_id || !RELAY_API_URL || !RELAY_API_KEY) {
210
+ return false;
211
+ }
212
+
213
+ // Extract current session metadata (without started_at - that's preserved server-side)
214
+ let metadata = { cwd };
215
+ try {
216
+ const possiblePaths = [
217
+ join(__dirname, '..', '..', 'lib', 'session', 'metadata.js'),
218
+ join(process.env.HOME || process.env.USERPROFILE || '', '.teleportation', 'lib', 'session', 'metadata.js'),
219
+ './lib/session/metadata.js'
220
+ ];
221
+
222
+ let metadataModule = null;
223
+ for (const path of possiblePaths) {
224
+ try {
225
+ metadataModule = await import('file://' + path);
226
+ break;
227
+ } catch (e) {
228
+ // Try next path
229
+ }
230
+ }
231
+
232
+ if (metadataModule && metadataModule.extractSessionMetadata && cwd) {
233
+ const extracted = await metadataModule.extractSessionMetadata(cwd);
234
+ extracted.session_id = session_id;
235
+ metadata = extracted;
236
+ }
237
+ } catch (e) {
238
+ // If metadata extraction fails, fall back to basic metadata
239
+ if (env.DEBUG) {
240
+ console.error('[SessionUpdate] Failed to extract metadata:', e.message);
241
+ }
242
+ }
243
+
244
+ try {
245
+ const response = await fetch(`${RELAY_API_URL}/api/sessions/${session_id}`, {
246
+ method: 'PATCH',
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
250
+ },
251
+ body: JSON.stringify({ meta: metadata })
252
+ });
253
+
254
+ if (response.ok || response.status === 200) {
255
+ if (env.DEBUG) {
256
+ console.error(`[SessionUpdate] Session metadata updated: ${session_id}`);
257
+ }
258
+ return true;
259
+ } else if (response.status === 404) {
260
+ // Session doesn't exist - need to register first
261
+ if (env.DEBUG) {
262
+ console.error(`[SessionUpdate] Session not found, needs registration: ${session_id}`);
263
+ }
264
+ return false;
265
+ }
266
+ } catch (e) {
267
+ if (env.DEBUG) {
268
+ console.error('[SessionUpdate] Failed to update session:', e.message);
269
+ }
270
+ }
271
+
272
+ return false;
273
+ }
274
+
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { stdin, exit, env } from 'node:process';
4
+ import { readFile, unlink } from 'fs/promises';
5
+ import { tmpdir } from 'os';
6
+ import { join } from 'path';
7
+
8
+ const readStdin = () => new Promise((resolve, reject) => {
9
+ let data='';
10
+ stdin.setEncoding('utf8');
11
+ stdin.on('data', c => data += c);
12
+ stdin.on('end', () => resolve(data));
13
+ stdin.on('error', reject);
14
+ });
15
+
16
+ (async () => {
17
+ let input = {};
18
+ try {
19
+ const raw = await readStdin();
20
+ input = JSON.parse(raw || '{}');
21
+ } catch {}
22
+
23
+ const { session_id } = input || {};
24
+
25
+ // Load config from encrypted credentials, legacy config file, or env vars
26
+ let config;
27
+ try {
28
+ const { loadConfig } = await import('./config-loader.mjs');
29
+ config = await loadConfig();
30
+ } catch (e) {
31
+ // Fallback to environment variables if config loader fails
32
+ config = {
33
+ relayApiUrl: env.RELAY_API_URL || '',
34
+ relayApiKey: env.RELAY_API_KEY || ''
35
+ };
36
+ }
37
+
38
+ const RELAY_API_URL = config.relayApiUrl || '';
39
+ const RELAY_API_KEY = config.relayApiKey || '';
40
+ const DAEMON_PORT = config.daemonPort || env.TELEPORTATION_DAEMON_PORT || '3050';
41
+ const DAEMON_ENABLED = config.daemonEnabled !== false && env.TELEPORTATION_DAEMON_ENABLED !== 'false';
42
+
43
+ const updateSessionDaemonState = async (updates) => {
44
+ if (!session_id || !RELAY_API_URL || !RELAY_API_KEY) return;
45
+ try {
46
+ await fetch(`${RELAY_API_URL}/api/sessions/${session_id}/daemon-state`, {
47
+ method: 'PATCH',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
51
+ },
52
+ body: JSON.stringify(updates)
53
+ });
54
+ } catch {}
55
+ };
56
+
57
+ // Kill heartbeat background process if running
58
+ if (session_id) {
59
+ try {
60
+ const pidFile = join(tmpdir(), `teleportation-heartbeat-${session_id}.pid`);
61
+ const pidContent = await readFile(pidFile, 'utf8');
62
+
63
+ // Parse PID file (now JSON format with session_id validation)
64
+ let pidData;
65
+ try {
66
+ pidData = JSON.parse(pidContent);
67
+ } catch {
68
+ // Fallback for old format (plain PID number)
69
+ pidData = { pid: parseInt(pidContent.trim(), 10) };
70
+ }
71
+
72
+ const pid = pidData.pid;
73
+
74
+ // Validate session_id matches (prevents killing wrong process)
75
+ if (pid && !isNaN(pid)) {
76
+ if (pidData.session_id && pidData.session_id !== session_id) {
77
+ console.error(`[SessionEnd] PID file session_id mismatch: expected ${session_id}, got ${pidData.session_id}`);
78
+ } else {
79
+ try {
80
+ // Verify process exists before killing
81
+ process.kill(pid, 0); // Signal 0 checks existence without killing
82
+
83
+ // Process exists, safe to kill
84
+ process.kill(pid, 'SIGTERM');
85
+ console.log(`[SessionEnd] Killed heartbeat process (PID: ${pid})`);
86
+ } catch (killError) {
87
+ if (killError.code === 'ESRCH') {
88
+ // Process already dead, that's okay
89
+ console.log(`[SessionEnd] Heartbeat process already terminated (PID: ${pid})`);
90
+ } else {
91
+ // Permission error or other issue
92
+ console.error(`[SessionEnd] Failed to kill heartbeat:`, killError.message);
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ // Delete PID file
99
+ try {
100
+ await unlink(pidFile);
101
+ } catch (unlinkError) {
102
+ // Ignore errors - file might not exist
103
+ }
104
+ } catch (error) {
105
+ // Ignore errors reading PID file - heartbeat might not have been started
106
+ }
107
+
108
+ // Delete registration marker file
109
+ try {
110
+ const registrationMarker = join(tmpdir(), `teleportation-session-${session_id}.registered`);
111
+ await unlink(registrationMarker);
112
+ } catch (e) {
113
+ // Ignore errors - marker might not exist
114
+ }
115
+
116
+ // Clean up model tracking files
117
+ try {
118
+ const lastModelFile = join(tmpdir(), `teleportation-last-model-${session_id}.txt`);
119
+ await unlink(lastModelFile);
120
+ } catch (e) {
121
+ // Ignore errors - file may not exist
122
+ }
123
+
124
+ // Clean up model change marker file (if it exists)
125
+ try {
126
+ const modelChangeMarker = join(tmpdir(), `teleportation-model-changing-${session_id}.txt`);
127
+ await unlink(modelChangeMarker);
128
+ } catch (e) {
129
+ // Ignore errors - file may not exist
130
+ }
131
+ }
132
+
133
+ // Clean up session resources
134
+ if (session_id) {
135
+ try {
136
+ // Cache module loading to prevent memory leaks from repeated imports
137
+ if (!global.__teleportationCleanup) {
138
+ const { fileURLToPath } = await import('url');
139
+ const { dirname, join } = await import('path');
140
+ const __filename = fileURLToPath(import.meta.url);
141
+ const __dirname = dirname(__filename);
142
+
143
+ // Try to load cleanup utility from multiple possible locations
144
+ const possiblePaths = [
145
+ join(__dirname, '..', '..', 'lib', 'session', 'cleanup.js'),
146
+ join(process.env.HOME || process.env.USERPROFILE || '', '.teleportation', 'lib', 'session', 'cleanup.js'),
147
+ './lib/session/cleanup.js'
148
+ ];
149
+
150
+ for (const path of possiblePaths) {
151
+ try {
152
+ global.__teleportationCleanup = await import('file://' + path);
153
+ break;
154
+ } catch (e) {
155
+ // Try next path
156
+ }
157
+ }
158
+ }
159
+
160
+ const cleanupModule = global.__teleportationCleanup;
161
+ // Use cleanup utility if available, otherwise fall back to direct mute cache clearing
162
+ if (cleanupModule && cleanupModule.cleanupSession) {
163
+ await cleanupModule.cleanupSession(session_id);
164
+ } else {
165
+ // Fallback: try to clear mute cache directly
166
+ if (!global.__teleportationMuteChecker) {
167
+ const { fileURLToPath } = await import('url');
168
+ const { dirname, join } = await import('path');
169
+ const __filename = fileURLToPath(import.meta.url);
170
+ const __dirname = dirname(__filename);
171
+
172
+ const mutePaths = [
173
+ join(__dirname, '..', '..', 'lib', 'session', 'mute-checker.js'),
174
+ join(process.env.HOME || process.env.USERPROFILE || '', '.teleportation', 'lib', 'session', 'mute-checker.js'),
175
+ './lib/session/mute-checker.js'
176
+ ];
177
+
178
+ for (const path of mutePaths) {
179
+ try {
180
+ global.__teleportationMuteChecker = await import('file://' + path);
181
+ break;
182
+ } catch (e) {
183
+ // Try next path
184
+ }
185
+ }
186
+ }
187
+
188
+ const muteChecker = global.__teleportationMuteChecker;
189
+ if (muteChecker && muteChecker.clearMuteCache) {
190
+ muteChecker.clearMuteCache(session_id);
191
+ }
192
+ }
193
+ } catch (e) {
194
+ // Ignore errors in cleanup - session end should always succeed
195
+ }
196
+ }
197
+
198
+ if (session_id) {
199
+ await updateSessionDaemonState({
200
+ status: 'stopped',
201
+ started_reason: null,
202
+ is_away: false,
203
+ stopped_reason: 'session_end'
204
+ });
205
+ }
206
+
207
+ // Deregister session with daemon
208
+ if (DAEMON_ENABLED && session_id) {
209
+ try {
210
+ const daemonUrl = `http://127.0.0.1:${DAEMON_PORT}`;
211
+
212
+ // Add timeout to prevent hanging if daemon is unresponsive
213
+ const controller = new AbortController();
214
+ const timeoutId = setTimeout(() => controller.abort(), 2000); // 2 second timeout
215
+
216
+ try {
217
+ await fetch(`${daemonUrl}/sessions/deregister`, {
218
+ method: 'POST',
219
+ headers: { 'Content-Type': 'application/json' },
220
+ body: JSON.stringify({ session_id }),
221
+ signal: controller.signal
222
+ });
223
+ clearTimeout(timeoutId);
224
+ if (env.DEBUG) {
225
+ console.error(`[SessionEnd] Deregistered session from daemon: ${session_id}`);
226
+ }
227
+ } catch (fetchError) {
228
+ clearTimeout(timeoutId);
229
+ throw fetchError; // Re-throw to be caught by outer try-catch
230
+ }
231
+ } catch (e) {
232
+ // Ignore errors - daemon might not be running
233
+ if (env.DEBUG) {
234
+ console.error(`[SessionEnd] Failed to deregister from daemon:`, e.message);
235
+ }
236
+ }
237
+ }
238
+
239
+ // Deregister session with relay API
240
+ if (session_id && RELAY_API_URL && RELAY_API_KEY) {
241
+ try {
242
+ await fetch(`${RELAY_API_URL}/api/sessions/deregister`, {
243
+ method: 'POST',
244
+ headers: {
245
+ 'Content-Type': 'application/json',
246
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
247
+ },
248
+ body: JSON.stringify({ session_id })
249
+ });
250
+ } catch (e) {
251
+ // Ignore errors - session end should always succeed even if API is unavailable
252
+ }
253
+ }
254
+
255
+ return exit(0);
256
+ })();