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,93 @@
1
+ #!/usr/bin/env bun
2
+ // Shared config loader for all hooks
3
+ // Reads from encrypted credentials (~/.teleportation/credentials), then ~/.teleportation-config.json, then env vars
4
+
5
+ import { readFile } from 'node:fs/promises';
6
+ import { homedir } from 'node:os';
7
+ import { join, dirname } from 'node:path';
8
+ import { env } from 'node:process';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ // Get __dirname equivalent for ES modules
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ export async function loadConfig() {
16
+ // Test override: allow forcing config to be loaded from environment variables only
17
+ // This is useful for unit tests that mock the relay API.
18
+ if (env.TELEPORTATION_CONFIG_FROM_ENV_ONLY === 'true') {
19
+ return {
20
+ relayApiUrl: env.RELAY_API_URL || '',
21
+ relayApiKey: env.RELAY_API_KEY || '',
22
+ userToken: env.DETACH_USER_TOKEN || '',
23
+ slackWebhookUrl: env.SLACK_WEBHOOK_URL || ''
24
+ };
25
+ }
26
+
27
+ // Priority 1: Try to load from encrypted credentials file
28
+ try {
29
+ // Try multiple possible paths for the credential manager
30
+ const possiblePaths = [
31
+ // If hook is still in project directory
32
+ join(process.cwd(), 'lib', 'auth', 'credentials.js'),
33
+ // If installed globally, try common locations
34
+ join(homedir(), '.teleportation', 'lib', 'auth', 'credentials.js'),
35
+ // Development path (if running from workspace)
36
+ join(homedir(), 'dev_env', 'teleporter', 'teleportation', 'lib', 'auth', 'credentials.js'),
37
+ // Try relative to hook location (if hooks are symlinked)
38
+ join(__dirname, '..', '..', 'lib', 'auth', 'credentials.js')
39
+ ];
40
+
41
+ let CredentialManager = null;
42
+ for (const credentialsPath of possiblePaths) {
43
+ try {
44
+ const module = await import(credentialsPath);
45
+ CredentialManager = module.CredentialManager;
46
+ if (CredentialManager) break;
47
+ } catch (e) {
48
+ // Try next path
49
+ continue;
50
+ }
51
+ }
52
+
53
+ if (CredentialManager) {
54
+ const manager = new CredentialManager();
55
+ const credentials = await manager.load();
56
+
57
+ if (credentials) {
58
+ return {
59
+ relayApiUrl: credentials.relayApiUrl || credentials.relay_api_url || '',
60
+ relayApiKey: credentials.relayApiKey || credentials.apiKey || credentials.relay_api_key || '',
61
+ userToken: credentials.userToken || credentials.user_token || '',
62
+ slackWebhookUrl: credentials.slackWebhookUrl || credentials.slack_webhook_url || ''
63
+ };
64
+ }
65
+ }
66
+ } catch (e) {
67
+ // Credential manager not available or credentials don't exist, continue to fallback
68
+ }
69
+
70
+ // Priority 2: Try to load from legacy config file
71
+ const configPath = join(homedir(), '.teleportation-config.json');
72
+
73
+ try {
74
+ const content = await readFile(configPath, 'utf8');
75
+ const config = JSON.parse(content);
76
+ return {
77
+ relayApiUrl: config.relay_api_url || '',
78
+ relayApiKey: config.relay_api_key || '',
79
+ userToken: config.user_token || '',
80
+ slackWebhookUrl: config.slack_webhook_url || ''
81
+ };
82
+ } catch (e) {
83
+ // Config file doesn't exist, continue to fallback
84
+ }
85
+
86
+ // Priority 3: Fall back to environment variables
87
+ return {
88
+ relayApiUrl: env.RELAY_API_URL || '',
89
+ relayApiKey: env.RELAY_API_KEY || '',
90
+ userToken: env.DETACH_USER_TOKEN || '',
91
+ slackWebhookUrl: env.SLACK_WEBHOOK_URL || ''
92
+ };
93
+ }
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Heartbeat Background Process
4
+ * Runs continuously while Claude Code session is active
5
+ * Sends periodic heartbeats to relay API to keep session alive
6
+ *
7
+ * This is NOT a hook - it's a background process spawned by session_start.mjs
8
+ * It runs detached and continues until killed by session_end.mjs
9
+ */
10
+
11
+ import { env, exit, pid as processPid } from 'node:process';
12
+ import { writeFile, unlink } from 'fs/promises';
13
+ import { tmpdir, homedir } from 'os';
14
+ import { join, dirname } from 'path';
15
+ import { fileURLToPath, pathToFileURL } from 'url';
16
+
17
+ // Read configuration from environment variables (secure - not visible in process list)
18
+ const SESSION_ID = process.argv[2] || env.SESSION_ID;
19
+ const RELAY_API_URL = env.RELAY_API_URL;
20
+ const RELAY_API_KEY = env.RELAY_API_KEY;
21
+ const HEARTBEAT_INTERVAL = Math.max(100, Math.min(600000,
22
+ parseInt(env.HEARTBEAT_INTERVAL || '120000', 10)
23
+ )); // Default 2 minutes, min 100ms (for testing), max 10min
24
+ const START_DELAY = Math.max(100, Math.min(60000,
25
+ parseInt(env.START_DELAY || '5000', 10)
26
+ )); // Default 5 seconds, min 100ms (for testing), max 1min
27
+ const MAX_FAILURES = Math.max(1, Math.min(10,
28
+ parseInt(env.MAX_FAILURES || '3', 10)
29
+ )); // Default 3, min 1, max 10
30
+ const FETCH_TIMEOUT = Math.max(100, Math.min(30000,
31
+ parseInt(env.FETCH_TIMEOUT || '5000', 10)
32
+ )); // Default 5s, min 100ms (for testing), max 30s
33
+ const DEBUG = env.TELEPORTATION_DEBUG === 'true';
34
+
35
+ // Get __dirname equivalent for ES modules
36
+ const __filename = fileURLToPath(import.meta.url);
37
+ const __dirname = dirname(__filename);
38
+
39
+ if (!SESSION_ID || !RELAY_API_URL || !RELAY_API_KEY) {
40
+ console.error('[Heartbeat] Missing required environment variables: SESSION_ID, RELAY_API_URL, RELAY_API_KEY');
41
+ exit(1);
42
+ }
43
+
44
+ const PID_FILE = join(tmpdir(), `teleportation-heartbeat-${SESSION_ID}.pid`);
45
+ let intervalHandle = null;
46
+ let heartbeatCount = 0;
47
+ let failureCount = 0;
48
+ let lastDaemonCheck = 0;
49
+ // Make interval configurable via environment variable (default 60 seconds)
50
+ const DAEMON_CHECK_INTERVAL = Math.max(10000, Math.min(300000,
51
+ parseInt(env.DAEMON_CHECK_INTERVAL || '60000', 10)
52
+ )); // Default 60 seconds, min 10s, max 5min
53
+
54
+ /**
55
+ * Check if daemon is running and start it if not
56
+ */
57
+ async function ensureDaemonRunning() {
58
+ // Early return with throttle check BEFORE any async operations
59
+ // This prevents multiple concurrent executions of the expensive module import logic
60
+ const now = Date.now();
61
+ if (now - lastDaemonCheck < DAEMON_CHECK_INTERVAL) {
62
+ return;
63
+ }
64
+ // Set timestamp immediately after check to prevent race conditions
65
+ lastDaemonCheck = now;
66
+
67
+ try {
68
+ // Try to import lifecycle module from installed location
69
+ const possibleLocations = [
70
+ join(homedir(), '.teleportation', 'lib', 'daemon', 'lifecycle.js'),
71
+ join(__dirname, '..', '..', 'lib', 'daemon', 'lifecycle.js'),
72
+ env.TELEPORTATION_LIFECYCLE_PATH
73
+ ].filter(Boolean);
74
+
75
+ const { access } = await import('fs/promises');
76
+ let lifecyclePath = null;
77
+ for (const location of possibleLocations) {
78
+ try {
79
+ await access(location);
80
+ lifecyclePath = location;
81
+ break;
82
+ } catch {
83
+ // Try next location
84
+ }
85
+ }
86
+
87
+ if (!lifecyclePath) {
88
+ if (DEBUG) {
89
+ console.log('[Heartbeat] Lifecycle module not found, skipping daemon check');
90
+ }
91
+ return;
92
+ }
93
+
94
+ // Cache module imports to avoid re-importing on every check
95
+ // Use pathToFileURL for proper Windows path handling
96
+ const pidManagerPath = join(dirname(lifecyclePath), 'pid-manager.js');
97
+
98
+ if (!lifecycleModule || !pidManagerModule) {
99
+ // Only import once, then cache
100
+ // Use pathToFileURL for cross-platform compatibility (especially Windows)
101
+ lifecycleModule = await import(pathToFileURL(lifecyclePath).href);
102
+ pidManagerModule = await import(pathToFileURL(pidManagerPath).href);
103
+ }
104
+
105
+ const lifecycle = lifecycleModule;
106
+ const { checkDaemonStatus } = pidManagerModule;
107
+
108
+ // Check daemon status
109
+ const status = await checkDaemonStatus();
110
+ if (!status.running && !daemonStartInProgress) {
111
+ // Set flag to prevent concurrent start attempts
112
+ daemonStartInProgress = true;
113
+
114
+ if (DEBUG) {
115
+ console.log(`[Heartbeat] Daemon not running, attempting to start...`);
116
+ }
117
+
118
+ try {
119
+ await lifecycle.startDaemon({ silent: true });
120
+ if (DEBUG) {
121
+ console.log(`[Heartbeat] Daemon started successfully`);
122
+ }
123
+ } catch (error) {
124
+ // Don't fail heartbeat if daemon start fails - it might already be starting
125
+ if (DEBUG) {
126
+ console.log(`[Heartbeat] Daemon start attempt: ${error.message}`);
127
+ }
128
+ } finally {
129
+ // Always reset flag, even if start fails
130
+ daemonStartInProgress = false;
131
+ }
132
+ }
133
+ } catch (error) {
134
+ // Silently fail - daemon check shouldn't break heartbeat
135
+ if (DEBUG) {
136
+ console.log(`[Heartbeat] Error checking daemon: ${error.message}`);
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Check for pending approvals and handle them
143
+ */
144
+ async function checkAndHandlePendingApprovals() {
145
+ try {
146
+ // Fetch pending and allowed approvals for this session
147
+ const controller = new AbortController();
148
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
149
+
150
+ const [pendingResponse, allowedResponse] = await Promise.all([
151
+ fetch(`${RELAY_API_URL}/api/approvals?status=pending&session_id=${SESSION_ID}`, {
152
+ headers: {
153
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
154
+ },
155
+ signal: controller.signal
156
+ }),
157
+ fetch(`${RELAY_API_URL}/api/approvals?status=allowed&session_id=${SESSION_ID}`, {
158
+ headers: {
159
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
160
+ },
161
+ signal: controller.signal
162
+ })
163
+ ]);
164
+
165
+ clearTimeout(timeoutId);
166
+
167
+ if (!pendingResponse.ok && !allowedResponse.ok) {
168
+ return; // Skip if API calls fail
169
+ }
170
+
171
+ const pending = pendingResponse.ok ? await pendingResponse.json() : [];
172
+ const allowed = allowedResponse.ok ? await allowedResponse.json() : [];
173
+
174
+ // Find allowed approvals that haven't been acknowledged yet
175
+ const unacknowledged = allowed.filter(a => !a.acknowledgedAt);
176
+
177
+ if (unacknowledged.length > 0 && DEBUG) {
178
+ console.log(`[Heartbeat] Found ${unacknowledged.length} approved but unacknowledged approval(s)`);
179
+ }
180
+
181
+ // Acknowledge approved approvals (fire-and-forget, don't block heartbeat)
182
+ for (const approval of unacknowledged) {
183
+ try {
184
+ fetch(`${RELAY_API_URL}/api/approvals/${approval.id}/ack`, {
185
+ method: 'POST',
186
+ headers: {
187
+ 'Content-Type': 'application/json',
188
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
189
+ },
190
+ body: JSON.stringify({ processed: true })
191
+ }).catch(() => {}); // Ignore errors - acknowledgment is optional
192
+ } catch (e) {
193
+ // Ignore errors
194
+ }
195
+ }
196
+
197
+ // Log if there are pending approvals waiting for user decision
198
+ if (pending.length > 0 && DEBUG) {
199
+ console.log(`[Heartbeat] Session has ${pending.length} pending approval(s) waiting for user decision`);
200
+ }
201
+ } catch (error) {
202
+ // Silently fail - approval checking shouldn't break heartbeat
203
+ if (DEBUG) {
204
+ console.log(`[Heartbeat] Error checking approvals: ${error.message}`);
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Send heartbeat to relay API
211
+ */
212
+ async function sendHeartbeat() {
213
+ // Increment count before sending (so first heartbeat is #1, not #0)
214
+ heartbeatCount++;
215
+
216
+ try {
217
+ // Add timeout to prevent hanging on unreachable servers
218
+ const controller = new AbortController();
219
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
220
+
221
+ const response = await fetch(`${RELAY_API_URL}/api/sessions/${SESSION_ID}/heartbeat`, {
222
+ method: 'POST',
223
+ headers: {
224
+ 'Content-Type': 'application/json',
225
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
226
+ },
227
+ body: JSON.stringify({
228
+ timestamp: new Date().toISOString(),
229
+ pid: processPid,
230
+ count: heartbeatCount
231
+ }),
232
+ signal: controller.signal
233
+ });
234
+
235
+ clearTimeout(timeoutId); // Clear timeout if request succeeds
236
+
237
+ if (response.ok) {
238
+ failureCount = 0; // Reset failure count on success
239
+
240
+ // Check daemon status and start if needed (asynchronously, don't block heartbeat)
241
+ ensureDaemonRunning().catch(() => {});
242
+
243
+ // Check for pending approvals after successful heartbeat
244
+ // Do this asynchronously so it doesn't block the heartbeat
245
+ checkAndHandlePendingApprovals().catch(() => {});
246
+
247
+ if (DEBUG) {
248
+ console.log(`[Heartbeat] Sent #${heartbeatCount} for session ${SESSION_ID}`);
249
+ }
250
+ } else {
251
+ heartbeatCount--; // Rollback count on failure
252
+ failureCount++;
253
+ console.error(`[Heartbeat] Failed (${response.status}): ${await response.text()}`);
254
+
255
+ if (failureCount >= MAX_FAILURES) {
256
+ console.error(`[Heartbeat] Max failures reached (${MAX_FAILURES}), stopping`);
257
+ await cleanup();
258
+ }
259
+ }
260
+ } catch (error) {
261
+ heartbeatCount--; // Rollback count on error
262
+ failureCount++;
263
+ console.error(`[Heartbeat] Error sending heartbeat:`, error.message);
264
+
265
+ if (failureCount >= MAX_FAILURES) {
266
+ console.error(`[Heartbeat] Max failures reached (${MAX_FAILURES}), stopping`);
267
+ await cleanup();
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Cleanup and exit
274
+ */
275
+ async function cleanup() {
276
+ console.log(`[Heartbeat] Cleaning up session ${SESSION_ID}`);
277
+
278
+ if (intervalHandle) {
279
+ clearInterval(intervalHandle);
280
+ intervalHandle = null;
281
+ }
282
+
283
+ // Remove PID file
284
+ try {
285
+ await unlink(PID_FILE);
286
+ } catch (error) {
287
+ // Ignore errors - file might not exist
288
+ }
289
+
290
+ exit(0);
291
+ }
292
+
293
+ /**
294
+ * Start heartbeat loop
295
+ */
296
+ async function start() {
297
+ try {
298
+ // Write PID file with session_id for validation (prevents killing wrong process)
299
+ await writeFile(PID_FILE, JSON.stringify({
300
+ pid: processPid,
301
+ session_id: SESSION_ID,
302
+ started_at: Date.now()
303
+ }), { mode: 0o600 });
304
+
305
+ if (DEBUG) {
306
+ console.log(`[Heartbeat] Started for session ${SESSION_ID} (PID: ${processPid})`);
307
+ console.log(`[Heartbeat] Interval: ${HEARTBEAT_INTERVAL}ms, Start delay: ${START_DELAY}ms, Max failures: ${MAX_FAILURES}, Fetch timeout: ${FETCH_TIMEOUT}ms`);
308
+ }
309
+
310
+ // Wait for initial delay before first heartbeat
311
+ setTimeout(() => {
312
+ // Send first heartbeat immediately after delay
313
+ sendHeartbeat();
314
+
315
+ // Then set up interval for subsequent heartbeats
316
+ intervalHandle = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL);
317
+ }, START_DELAY);
318
+
319
+ // Handle graceful shutdown
320
+ process.on('SIGTERM', cleanup);
321
+ process.on('SIGINT', cleanup);
322
+ process.on('SIGHUP', cleanup);
323
+
324
+ } catch (error) {
325
+ console.error(`[Heartbeat] Failed to start:`, error.message);
326
+ exit(1);
327
+ }
328
+ }
329
+
330
+ // Start the heartbeat process
331
+ start();
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { stdin, stdout, exit, env } from 'node:process';
4
+
5
+ const readStdin = () => new Promise((resolve, reject) => {
6
+ let data='';
7
+ stdin.setEncoding('utf8');
8
+ stdin.on('data', c => data += c);
9
+ stdin.on('end', () => resolve(data));
10
+ stdin.on('error', reject);
11
+ });
12
+
13
+ (async () => {
14
+ let input = {};
15
+ try {
16
+ const raw = await readStdin();
17
+ input = JSON.parse(raw || '{}');
18
+ } catch {}
19
+
20
+ const SLACK_WEBHOOK_URL = env.SLACK_WEBHOOK_URL || '';
21
+
22
+ try {
23
+ if (SLACK_WEBHOOK_URL) {
24
+ const text = `Notification: ${input?.message || input?.type || 'Claude Code'}`;
25
+ await fetch(SLACK_WEBHOOK_URL, {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({ text })
29
+ });
30
+ }
31
+ } catch {}
32
+
33
+ stdout.write(JSON.stringify({ suppressOutput: true }));
34
+ return exit(0);
35
+ })();