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,336 @@
1
+ import { spawn } from 'child_process';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+ import {
5
+ checkDaemonStatus,
6
+ acquirePidLock,
7
+ releasePidLock,
8
+ isProcessRunning
9
+ } from './pid-manager.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ // Path to daemon entry point (relative to lifecycle.js location)
15
+ const DAEMON_SCRIPT = join(__dirname, 'teleportation-daemon.js');
16
+
17
+ /**
18
+ * Start the daemon process
19
+ * @param {Object} options - Start options
20
+ * @param {boolean} options.detached - Run daemon as detached process (default: true)
21
+ * @param {boolean} options.silent - Suppress output (default: true)
22
+ * @returns {Promise<{pid: number, success: boolean}>}
23
+ */
24
+ export async function startDaemon(options = {}) {
25
+ const { detached = true, silent = true } = options;
26
+
27
+ // Check if daemon is already running
28
+ const status = await checkDaemonStatus();
29
+ if (status.running) {
30
+ throw new Error(`Daemon already running with PID ${status.pid}`);
31
+ }
32
+
33
+ // Clean up stale PID file if exists
34
+ if (status.stale) {
35
+ await releasePidLock(status.pid);
36
+ }
37
+
38
+ // Spawn daemon process
39
+ const child = spawn(
40
+ process.execPath, // Use same Node.js executable
41
+ [DAEMON_SCRIPT],
42
+ {
43
+ detached,
44
+ stdio: silent ? 'ignore' : 'inherit',
45
+ env: {
46
+ ...process.env,
47
+ TELEPORTATION_DAEMON: 'true'
48
+ }
49
+ }
50
+ );
51
+
52
+ // Detach from parent if requested
53
+ if (detached) {
54
+ child.unref();
55
+ }
56
+
57
+ // Wait a moment to ensure process started (increase wait time for CI)
58
+ await new Promise(resolve => setTimeout(resolve, 1000));
59
+
60
+ // Verify daemon is running (check multiple times for slow CI)
61
+ let newStatus = await checkDaemonStatus();
62
+ if (!newStatus.running) {
63
+ // Wait a bit more and check again
64
+ await new Promise(resolve => setTimeout(resolve, 1000));
65
+ newStatus = await checkDaemonStatus();
66
+ if (!newStatus.running) {
67
+ throw new Error('Daemon failed to start');
68
+ }
69
+ }
70
+
71
+ return {
72
+ pid: child.pid,
73
+ success: true
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Stop the daemon process
79
+ * @param {Object} options - Stop options
80
+ * @param {number} options.timeout - Timeout in ms for graceful shutdown (default: 5000)
81
+ * @param {boolean} options.force - Force kill if graceful shutdown fails (default: true)
82
+ * @returns {Promise<{success: boolean, forced: boolean}>}
83
+ */
84
+ export async function stopDaemon(options = {}) {
85
+ const { timeout = 5000, force = true } = options;
86
+
87
+ // Check daemon status
88
+ const status = await checkDaemonStatus();
89
+ if (!status.running) {
90
+ return { success: true, forced: false };
91
+ }
92
+
93
+ const { pid } = status;
94
+
95
+ try {
96
+ // Send SIGTERM for graceful shutdown
97
+ process.kill(pid, 'SIGTERM');
98
+
99
+ // Wait for process to exit
100
+ const startTime = Date.now();
101
+ while (Date.now() - startTime < timeout) {
102
+ if (!isProcessRunning(pid)) {
103
+ await releasePidLock(pid);
104
+ return { success: true, forced: false };
105
+ }
106
+ await new Promise(resolve => setTimeout(resolve, 100));
107
+ }
108
+
109
+ // Timeout reached, force kill if requested
110
+ if (force) {
111
+ process.kill(pid, 'SIGKILL');
112
+
113
+ // Wait a moment for kill to take effect
114
+ await new Promise(resolve => setTimeout(resolve, 200));
115
+
116
+ if (!isProcessRunning(pid)) {
117
+ await releasePidLock(pid);
118
+ return { success: true, forced: true };
119
+ }
120
+
121
+ throw new Error('Failed to kill daemon process');
122
+ }
123
+
124
+ return { success: false, forced: false };
125
+ } catch (err) {
126
+ // Process might have already exited
127
+ if (err.code === 'ESRCH') {
128
+ await releasePidLock(pid);
129
+ return { success: true, forced: false };
130
+ }
131
+ throw err;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Restart the daemon process
137
+ * @param {Object} options - Restart options
138
+ * @param {number} options.stopTimeout - Timeout for stop operation (default: 5000)
139
+ * @param {boolean} options.force - Force kill on stop timeout (default: true)
140
+ * @returns {Promise<{pid: number, success: boolean, wasRunning: boolean}>}
141
+ */
142
+ export async function restartDaemon(options = {}) {
143
+ const { stopTimeout = 5000, force = true } = options;
144
+
145
+ // Check if daemon is running
146
+ const status = await checkDaemonStatus();
147
+ const wasRunning = status.running;
148
+
149
+ // Stop daemon if running
150
+ if (wasRunning) {
151
+ await stopDaemon({ timeout: stopTimeout, force });
152
+ }
153
+
154
+ // Start daemon
155
+ const result = await startDaemon();
156
+
157
+ return {
158
+ ...result,
159
+ wasRunning
160
+ };
161
+ }
162
+
163
+ function getRelayConfig() {
164
+ return {
165
+ url: process.env.RELAY_API_URL || '',
166
+ key: process.env.RELAY_API_KEY || ''
167
+ };
168
+ }
169
+
170
+ async function updateSessionDaemonState(sessionId, updates) {
171
+ const { url, key } = getRelayConfig();
172
+ if (!sessionId || !url || !key) return;
173
+
174
+ try {
175
+ const res = await fetch(`${url}/api/sessions/${encodeURIComponent(sessionId)}/daemon-state`, {
176
+ method: 'PATCH',
177
+ headers: {
178
+ 'Content-Type': 'application/json',
179
+ 'Authorization': `Bearer ${key}`
180
+ },
181
+ body: JSON.stringify(updates)
182
+ });
183
+
184
+ if (!res.ok && process.env.DEBUG) {
185
+ console.error('[lifecycle] Failed to update daemon_state:', res.status);
186
+ }
187
+ } catch (err) {
188
+ if (process.env.DEBUG) {
189
+ console.error('[lifecycle] Error updating daemon_state:', err.message);
190
+ }
191
+ }
192
+ }
193
+
194
+ export async function startDaemonIfNeeded(sessionId, reason = 'manual') {
195
+ const status = await checkDaemonStatus();
196
+
197
+ if (!status.running) {
198
+ await startDaemon();
199
+ }
200
+
201
+ if (sessionId) {
202
+ await updateSessionDaemonState(sessionId, {
203
+ status: 'running',
204
+ started_reason: reason
205
+ });
206
+ }
207
+ }
208
+
209
+ export async function stopDaemonIfNeeded(sessionId, reason = 'manual_stop') {
210
+ const status = await checkDaemonStatus();
211
+ if (!status.running) {
212
+ return { stopped: false, reason: 'not_running' };
213
+ }
214
+
215
+ // If relay is configured, check if other sessions still have daemon running
216
+ const { url, key } = getRelayConfig();
217
+ if (url && key) {
218
+ try {
219
+ const res = await fetch(`${url}/api/sessions`, {
220
+ headers: { 'Authorization': `Bearer ${key}` }
221
+ });
222
+ if (res.ok) {
223
+ const sessions = await res.json();
224
+ const otherRunning = sessions.some((s) =>
225
+ s.session_id !== sessionId &&
226
+ s.daemon_state &&
227
+ s.daemon_state.status === 'running'
228
+ );
229
+
230
+ if (otherRunning) {
231
+ return { stopped: false, reason: 'other_sessions_running' };
232
+ }
233
+ }
234
+ } catch (err) {
235
+ // Fail open: if we can't query sessions, we still attempt to stop daemon
236
+ if (process.env.DEBUG) {
237
+ console.error('[lifecycle] Failed to query sessions before stop:', err.message);
238
+ }
239
+ }
240
+ }
241
+
242
+ // Update daemon_state before stopping
243
+ if (sessionId) {
244
+ await updateSessionDaemonState(sessionId, {
245
+ status: 'stopped',
246
+ started_reason: null,
247
+ is_away: false
248
+ // stopped_reason could be added later if DaemonState schema is extended
249
+ });
250
+ }
251
+
252
+ const result = await stopDaemon();
253
+ return { stopped: result.success, reason };
254
+ }
255
+
256
+ /**
257
+ * Get daemon status
258
+ * @returns {Promise<{running: boolean, pid: number|null, uptime: number|null}>}
259
+ */
260
+ export async function getDaemonStatus() {
261
+ const status = await checkDaemonStatus();
262
+
263
+ // TODO: Add uptime calculation once daemon stores start time
264
+ // For now, we can only report running status and PID
265
+ return {
266
+ running: status.running,
267
+ pid: status.pid,
268
+ uptime: null // Will be implemented when daemon stores start timestamp
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Setup signal handlers for graceful daemon shutdown
274
+ * @param {Function} cleanupCallback - Async function to call before exit
275
+ */
276
+ export function setupSignalHandlers(cleanupCallback) {
277
+ const handleSignal = async (signal) => {
278
+ console.log(`Received ${signal}, shutting down gracefully...`);
279
+
280
+ try {
281
+ // Run cleanup callback
282
+ if (cleanupCallback) {
283
+ await cleanupCallback();
284
+ }
285
+
286
+ // Release PID lock
287
+ await releasePidLock(process.pid);
288
+
289
+ process.exit(0);
290
+ } catch (err) {
291
+ console.error('Error during cleanup:', err);
292
+ process.exit(1);
293
+ }
294
+ };
295
+
296
+ // Handle termination signals
297
+ process.on('SIGTERM', () => handleSignal('SIGTERM'));
298
+ process.on('SIGINT', () => handleSignal('SIGINT'));
299
+
300
+ // Handle uncaught errors
301
+ process.on('uncaughtException', async (err) => {
302
+ console.error('Uncaught exception:', err);
303
+ try {
304
+ if (cleanupCallback) {
305
+ await cleanupCallback();
306
+ }
307
+ await releasePidLock(process.pid);
308
+ } catch (cleanupErr) {
309
+ console.error('Error during cleanup:', cleanupErr);
310
+ }
311
+ process.exit(1);
312
+ });
313
+
314
+ process.on('unhandledRejection', async (reason) => {
315
+ console.error('Unhandled rejection:', reason);
316
+ try {
317
+ if (cleanupCallback) {
318
+ await cleanupCallback();
319
+ }
320
+ await releasePidLock(process.pid);
321
+ } catch (cleanupErr) {
322
+ console.error('Error during cleanup:', cleanupErr);
323
+ }
324
+ process.exit(1);
325
+ });
326
+ }
327
+
328
+ export default {
329
+ startDaemon,
330
+ stopDaemon,
331
+ restartDaemon,
332
+ getDaemonStatus,
333
+ setupSignalHandlers,
334
+ startDaemonIfNeeded,
335
+ stopDaemonIfNeeded
336
+ };
@@ -0,0 +1,160 @@
1
+ import { promises as fs } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ /**
6
+ * PID Manager
7
+ * Manages daemon process ID file for ensuring single daemon instance
8
+ */
9
+
10
+ const TELEPORTATION_DIR = join(homedir(), '.teleportation');
11
+ const PID_FILE = join(TELEPORTATION_DIR, 'daemon.pid');
12
+
13
+ /**
14
+ * Check if a process with given PID is running
15
+ */
16
+ export function isProcessRunning(pid) {
17
+ console.log(`[pid-manager] Checking if process ${pid} is running...`);
18
+ // Handle invalid PIDs
19
+ if (typeof pid !== 'number' || pid <= 0) {
20
+ return false;
21
+ }
22
+
23
+ try {
24
+ // Signal 0 checks if process exists without actually sending a signal
25
+ process.kill(pid, 0);
26
+ return true;
27
+ } catch (err) {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Read PID from file
34
+ * @returns {Promise<number|null>} PID or null if file doesn't exist or is invalid
35
+ */
36
+ export async function readPid() {
37
+ console.log(`[pid-manager] Reading PID from ${PID_FILE}...`);
38
+ try {
39
+ const content = await fs.readFile(PID_FILE, 'utf-8');
40
+ const pid = parseInt(content.trim(), 10);
41
+ if (isNaN(pid) || pid <= 0) {
42
+ return null;
43
+ }
44
+ return pid;
45
+ } catch (err) {
46
+ if (err.code === 'ENOENT') {
47
+ return null;
48
+ }
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Write PID to file with 600 permissions
55
+ * @param {number} pid - Process ID to write
56
+ */
57
+ export async function writePid(pid) {
58
+ console.log(`[pid-manager] Writing PID ${pid} to ${PID_FILE}...`);
59
+ // Ensure .teleportation directory exists
60
+ await fs.mkdir(TELEPORTATION_DIR, { recursive: true, mode: 0o700 });
61
+
62
+ // Write PID file with 600 permissions (owner read/write only)
63
+ await fs.writeFile(PID_FILE, String(pid), { mode: 0o600 });
64
+ }
65
+
66
+ /**
67
+ * Remove PID file
68
+ */
69
+ export async function removePid() {
70
+ console.log(`[pid-manager] Removing PID file ${PID_FILE}...`);
71
+ try {
72
+ await fs.unlink(PID_FILE);
73
+ } catch (err) {
74
+ if (err.code !== 'ENOENT') {
75
+ throw err;
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check if daemon is already running
82
+ * @returns {Promise<{running: boolean, pid: number|null, stale: boolean}>}
83
+ */
84
+ export async function checkDaemonStatus() {
85
+ const pid = await readPid();
86
+
87
+ if (pid === null) {
88
+ return { running: false, pid: null, stale: false };
89
+ }
90
+
91
+ const running = isProcessRunning(pid);
92
+
93
+ return {
94
+ running,
95
+ pid,
96
+ stale: !running // PID file exists but process is dead
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Clean up stale PID file (when process doesn't exist)
102
+ * @returns {Promise<boolean>} true if stale PID was removed
103
+ */
104
+ export async function cleanupStalePid() {
105
+ const status = await checkDaemonStatus();
106
+
107
+ if (status.stale) {
108
+ await removePid();
109
+ return true;
110
+ }
111
+
112
+ return false;
113
+ }
114
+
115
+ /**
116
+ * Acquire PID lock (write PID file, ensuring no other daemon is running)
117
+ * @param {number} pid - Process ID to write
118
+ * @throws {Error} if another daemon is already running
119
+ */
120
+ export async function acquirePidLock(pid) {
121
+ console.log(`[pid-manager] Acquiring PID lock for PID ${pid}...`);
122
+ // Clean up any stale PID files first
123
+ await cleanupStalePid();
124
+
125
+ // Check if daemon is running
126
+ const status = await checkDaemonStatus();
127
+
128
+ if (status.running) {
129
+ throw new Error(`Daemon already running with PID ${status.pid}`);
130
+ }
131
+
132
+ // Write our PID
133
+ await writePid(pid);
134
+ }
135
+
136
+ /**
137
+ * Release PID lock (remove PID file if it matches our PID)
138
+ * @param {number} pid - Our process ID
139
+ */
140
+ export async function releasePidLock(pid) {
141
+ console.log(`[pid-manager] Releasing PID lock for PID ${pid}...`);
142
+ const currentPid = await readPid();
143
+
144
+ // Only remove if PID file matches our process
145
+ if (currentPid === pid) {
146
+ await removePid();
147
+ }
148
+ }
149
+
150
+ export default {
151
+ isProcessRunning,
152
+ readPid,
153
+ writePid,
154
+ removePid,
155
+ checkDaemonStatus,
156
+ cleanupStalePid,
157
+ acquirePidLock,
158
+ releasePidLock,
159
+ PID_FILE
160
+ };