teleportation-cli 1.1.5 → 1.2.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/permission_request.mjs +326 -59
- package/.claude/hooks/post_tool_use.mjs +90 -0
- package/.claude/hooks/pre_tool_use.mjs +212 -293
- package/.claude/hooks/session-register.mjs +89 -104
- package/.claude/hooks/session_end.mjs +41 -42
- package/.claude/hooks/session_start.mjs +45 -60
- package/.claude/hooks/stop.mjs +752 -99
- package/.claude/hooks/user_prompt_submit.mjs +26 -3
- package/lib/cli/daemon-commands.js +1 -1
- package/lib/cli/teleport-commands.js +469 -0
- package/lib/daemon/daemon-v2.js +104 -0
- package/lib/daemon/lifecycle.js +56 -171
- package/lib/daemon/services/index.js +3 -0
- package/lib/daemon/services/polling-service.js +173 -0
- package/lib/daemon/services/queue-service.js +318 -0
- package/lib/daemon/services/session-service.js +115 -0
- package/lib/daemon/state.js +35 -0
- package/lib/daemon/task-executor-v2.js +413 -0
- package/lib/daemon/task-executor.js +270 -96
- package/lib/daemon/teleportation-daemon.js +709 -126
- package/lib/daemon/timeline-analyzer.js +215 -0
- package/lib/daemon/transcript-ingestion.js +696 -0
- package/lib/daemon/utils.js +91 -0
- package/lib/install/installer.js +184 -20
- package/lib/install/uhr-installer.js +136 -0
- package/lib/remote/providers/base-provider.js +46 -0
- package/lib/remote/providers/daytona-provider.js +58 -0
- package/lib/remote/providers/provider-factory.js +90 -19
- package/lib/remote/providers/sprites-provider.js +711 -0
- package/lib/teleport/exporters/claude-exporter.js +302 -0
- package/lib/teleport/exporters/gemini-exporter.js +307 -0
- package/lib/teleport/exporters/index.js +93 -0
- package/lib/teleport/exporters/interface.js +153 -0
- package/lib/teleport/fork-tracker.js +415 -0
- package/lib/teleport/git-committer.js +337 -0
- package/lib/teleport/index.js +48 -0
- package/lib/teleport/manager.js +620 -0
- package/lib/teleport/session-capture.js +282 -0
- package/package.json +9 -5
- package/teleportation-cli.cjs +488 -453
- package/.claude/hooks/heartbeat.mjs +0 -396
- package/lib/daemon/pid-manager.js +0 -183
|
@@ -1,396 +0,0 @@
|
|
|
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
|
-
// Module cache for lifecycle and pid-manager (imported once, reused)
|
|
50
|
-
let lifecycleModule = null;
|
|
51
|
-
let pidManagerModule = null;
|
|
52
|
-
// Flag to prevent concurrent daemon start attempts
|
|
53
|
-
let daemonStartInProgress = false;
|
|
54
|
-
// Make interval configurable via environment variable (default 60 seconds)
|
|
55
|
-
const DAEMON_CHECK_INTERVAL = Math.max(10000, Math.min(300000,
|
|
56
|
-
parseInt(env.DAEMON_CHECK_INTERVAL || '60000', 10)
|
|
57
|
-
)); // Default 60 seconds, min 10s, max 5min
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Update daemon state on relay
|
|
61
|
-
* @param {'running' | 'stopped'} status - Daemon status
|
|
62
|
-
* @param {string} reason - Reason for state change
|
|
63
|
-
*/
|
|
64
|
-
async function updateRelayDaemonState(status, reason) {
|
|
65
|
-
try {
|
|
66
|
-
const controller = new AbortController();
|
|
67
|
-
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
68
|
-
|
|
69
|
-
const response = await fetch(`${RELAY_API_URL}/api/sessions/${SESSION_ID}/daemon-state`, {
|
|
70
|
-
method: 'PATCH',
|
|
71
|
-
headers: {
|
|
72
|
-
'Content-Type': 'application/json',
|
|
73
|
-
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
74
|
-
},
|
|
75
|
-
body: JSON.stringify({
|
|
76
|
-
status,
|
|
77
|
-
started_at: status === 'running' ? Date.now() : null,
|
|
78
|
-
started_reason: status === 'running' ? reason : null
|
|
79
|
-
}),
|
|
80
|
-
signal: controller.signal
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
clearTimeout(timeoutId);
|
|
84
|
-
|
|
85
|
-
if (!response.ok && DEBUG) {
|
|
86
|
-
console.log(`[Heartbeat] Failed to update relay daemon state: ${response.status}`);
|
|
87
|
-
}
|
|
88
|
-
} catch (error) {
|
|
89
|
-
if (DEBUG) {
|
|
90
|
-
console.log(`[Heartbeat] Error updating relay daemon state: ${error.message}`);
|
|
91
|
-
}
|
|
92
|
-
throw error;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Check if daemon is running and start it if not
|
|
98
|
-
*/
|
|
99
|
-
async function ensureDaemonRunning() {
|
|
100
|
-
// Early return with throttle check BEFORE any async operations
|
|
101
|
-
// This prevents multiple concurrent executions of the expensive module import logic
|
|
102
|
-
const now = Date.now();
|
|
103
|
-
if (now - lastDaemonCheck < DAEMON_CHECK_INTERVAL) {
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
// Set timestamp immediately after check to prevent race conditions
|
|
107
|
-
lastDaemonCheck = now;
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
// Try to import lifecycle module from installed location
|
|
111
|
-
const possibleLocations = [
|
|
112
|
-
// npm global install location
|
|
113
|
-
'/usr/local/lib/node_modules/teleportation-cli/lib/daemon/lifecycle.js',
|
|
114
|
-
// User's npm global install (common on macOS with nvm/fnm)
|
|
115
|
-
join(homedir(), '.local', 'lib', 'node_modules', 'teleportation-cli', 'lib', 'daemon', 'lifecycle.js'),
|
|
116
|
-
// Legacy location
|
|
117
|
-
join(homedir(), '.teleportation', 'lib', 'daemon', 'lifecycle.js'),
|
|
118
|
-
// Relative to hooks dir (development)
|
|
119
|
-
join(__dirname, '..', '..', 'lib', 'daemon', 'lifecycle.js'),
|
|
120
|
-
// Environment override
|
|
121
|
-
env.TELEPORTATION_LIFECYCLE_PATH
|
|
122
|
-
].filter(Boolean);
|
|
123
|
-
|
|
124
|
-
const { access } = await import('fs/promises');
|
|
125
|
-
let lifecyclePath = null;
|
|
126
|
-
for (const location of possibleLocations) {
|
|
127
|
-
try {
|
|
128
|
-
await access(location);
|
|
129
|
-
lifecyclePath = location;
|
|
130
|
-
break;
|
|
131
|
-
} catch {
|
|
132
|
-
// Try next location
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (!lifecyclePath) {
|
|
137
|
-
if (DEBUG) {
|
|
138
|
-
console.log('[Heartbeat] Lifecycle module not found, skipping daemon check');
|
|
139
|
-
}
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Cache module imports to avoid re-importing on every check
|
|
144
|
-
// Use pathToFileURL for proper Windows path handling
|
|
145
|
-
const pidManagerPath = join(dirname(lifecyclePath), 'pid-manager.js');
|
|
146
|
-
|
|
147
|
-
if (!lifecycleModule || !pidManagerModule) {
|
|
148
|
-
// Only import once, then cache
|
|
149
|
-
// Use pathToFileURL for cross-platform compatibility (especially Windows)
|
|
150
|
-
lifecycleModule = await import(pathToFileURL(lifecyclePath).href);
|
|
151
|
-
pidManagerModule = await import(pathToFileURL(pidManagerPath).href);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const lifecycle = lifecycleModule;
|
|
155
|
-
const { checkDaemonStatus } = pidManagerModule;
|
|
156
|
-
|
|
157
|
-
// Check daemon status
|
|
158
|
-
const status = await checkDaemonStatus();
|
|
159
|
-
if (!status.running && !daemonStartInProgress) {
|
|
160
|
-
// Set flag to prevent concurrent start attempts
|
|
161
|
-
daemonStartInProgress = true;
|
|
162
|
-
|
|
163
|
-
if (DEBUG) {
|
|
164
|
-
console.log(`[Heartbeat] Daemon not running, attempting to start...`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
// Use startDaemonIfNeeded to both start the daemon AND update the relay
|
|
169
|
-
// This ensures the mobile UI knows the daemon is running
|
|
170
|
-
await lifecycle.startDaemonIfNeeded(SESSION_ID, 'heartbeat');
|
|
171
|
-
if (DEBUG) {
|
|
172
|
-
console.log(`[Heartbeat] Daemon started and relay updated successfully`);
|
|
173
|
-
}
|
|
174
|
-
} catch (error) {
|
|
175
|
-
// Don't fail heartbeat if daemon start fails - it might already be starting
|
|
176
|
-
if (DEBUG) {
|
|
177
|
-
console.log(`[Heartbeat] Daemon start attempt: ${error.message}`);
|
|
178
|
-
}
|
|
179
|
-
} finally {
|
|
180
|
-
// Always reset flag, even if start fails
|
|
181
|
-
daemonStartInProgress = false;
|
|
182
|
-
}
|
|
183
|
-
} else if (status.running) {
|
|
184
|
-
// Daemon is running - ensure relay knows about it
|
|
185
|
-
// This handles the case where daemon was started manually or by another session
|
|
186
|
-
try {
|
|
187
|
-
await updateRelayDaemonState('running', 'heartbeat');
|
|
188
|
-
if (DEBUG) {
|
|
189
|
-
console.log(`[Heartbeat] Synced daemon running state to relay`);
|
|
190
|
-
}
|
|
191
|
-
} catch (error) {
|
|
192
|
-
// Ignore errors - just a sync, not critical
|
|
193
|
-
if (DEBUG) {
|
|
194
|
-
console.log(`[Heartbeat] Failed to sync daemon state: ${error.message}`);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
} catch (error) {
|
|
199
|
-
// Silently fail - daemon check shouldn't break heartbeat
|
|
200
|
-
if (DEBUG) {
|
|
201
|
-
console.log(`[Heartbeat] Error checking daemon: ${error.message}`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Check for pending approvals and handle them
|
|
208
|
-
*/
|
|
209
|
-
async function checkAndHandlePendingApprovals() {
|
|
210
|
-
try {
|
|
211
|
-
// Fetch pending and allowed approvals for this session
|
|
212
|
-
const controller = new AbortController();
|
|
213
|
-
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
214
|
-
|
|
215
|
-
const [pendingResponse, allowedResponse] = await Promise.all([
|
|
216
|
-
fetch(`${RELAY_API_URL}/api/approvals?status=pending&session_id=${SESSION_ID}`, {
|
|
217
|
-
headers: {
|
|
218
|
-
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
219
|
-
},
|
|
220
|
-
signal: controller.signal
|
|
221
|
-
}),
|
|
222
|
-
fetch(`${RELAY_API_URL}/api/approvals?status=allowed&session_id=${SESSION_ID}`, {
|
|
223
|
-
headers: {
|
|
224
|
-
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
225
|
-
},
|
|
226
|
-
signal: controller.signal
|
|
227
|
-
})
|
|
228
|
-
]);
|
|
229
|
-
|
|
230
|
-
clearTimeout(timeoutId);
|
|
231
|
-
|
|
232
|
-
if (!pendingResponse.ok && !allowedResponse.ok) {
|
|
233
|
-
return; // Skip if API calls fail
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const pending = pendingResponse.ok ? await pendingResponse.json() : [];
|
|
237
|
-
const allowed = allowedResponse.ok ? await allowedResponse.json() : [];
|
|
238
|
-
|
|
239
|
-
// Find allowed approvals that haven't been acknowledged yet
|
|
240
|
-
const unacknowledged = allowed.filter(a => !a.acknowledgedAt);
|
|
241
|
-
|
|
242
|
-
if (unacknowledged.length > 0 && DEBUG) {
|
|
243
|
-
console.log(`[Heartbeat] Found ${unacknowledged.length} approved but unacknowledged approval(s)`);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Acknowledge approved approvals (fire-and-forget, don't block heartbeat)
|
|
247
|
-
for (const approval of unacknowledged) {
|
|
248
|
-
try {
|
|
249
|
-
fetch(`${RELAY_API_URL}/api/approvals/${approval.id}/ack`, {
|
|
250
|
-
method: 'POST',
|
|
251
|
-
headers: {
|
|
252
|
-
'Content-Type': 'application/json',
|
|
253
|
-
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
254
|
-
},
|
|
255
|
-
body: JSON.stringify({ processed: true })
|
|
256
|
-
}).catch(() => {}); // Ignore errors - acknowledgment is optional
|
|
257
|
-
} catch (e) {
|
|
258
|
-
// Ignore errors
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Log if there are pending approvals waiting for user decision
|
|
263
|
-
if (pending.length > 0 && DEBUG) {
|
|
264
|
-
console.log(`[Heartbeat] Session has ${pending.length} pending approval(s) waiting for user decision`);
|
|
265
|
-
}
|
|
266
|
-
} catch (error) {
|
|
267
|
-
// Silently fail - approval checking shouldn't break heartbeat
|
|
268
|
-
if (DEBUG) {
|
|
269
|
-
console.log(`[Heartbeat] Error checking approvals: ${error.message}`);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Send heartbeat to relay API
|
|
276
|
-
*/
|
|
277
|
-
async function sendHeartbeat() {
|
|
278
|
-
// Increment count before sending (so first heartbeat is #1, not #0)
|
|
279
|
-
heartbeatCount++;
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
// Add timeout to prevent hanging on unreachable servers
|
|
283
|
-
const controller = new AbortController();
|
|
284
|
-
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
285
|
-
|
|
286
|
-
const response = await fetch(`${RELAY_API_URL}/api/sessions/${SESSION_ID}/heartbeat`, {
|
|
287
|
-
method: 'POST',
|
|
288
|
-
headers: {
|
|
289
|
-
'Content-Type': 'application/json',
|
|
290
|
-
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
291
|
-
},
|
|
292
|
-
body: JSON.stringify({
|
|
293
|
-
timestamp: new Date().toISOString(),
|
|
294
|
-
pid: processPid,
|
|
295
|
-
count: heartbeatCount
|
|
296
|
-
}),
|
|
297
|
-
signal: controller.signal
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
clearTimeout(timeoutId); // Clear timeout if request succeeds
|
|
301
|
-
|
|
302
|
-
if (response.ok) {
|
|
303
|
-
failureCount = 0; // Reset failure count on success
|
|
304
|
-
|
|
305
|
-
// Check daemon status and start if needed (asynchronously, don't block heartbeat)
|
|
306
|
-
ensureDaemonRunning().catch(() => {});
|
|
307
|
-
|
|
308
|
-
// Check for pending approvals after successful heartbeat
|
|
309
|
-
// Do this asynchronously so it doesn't block the heartbeat
|
|
310
|
-
checkAndHandlePendingApprovals().catch(() => {});
|
|
311
|
-
|
|
312
|
-
if (DEBUG) {
|
|
313
|
-
console.log(`[Heartbeat] Sent #${heartbeatCount} for session ${SESSION_ID}`);
|
|
314
|
-
}
|
|
315
|
-
} else {
|
|
316
|
-
heartbeatCount--; // Rollback count on failure
|
|
317
|
-
failureCount++;
|
|
318
|
-
console.error(`[Heartbeat] Failed (${response.status}): ${await response.text()}`);
|
|
319
|
-
|
|
320
|
-
if (failureCount >= MAX_FAILURES) {
|
|
321
|
-
console.error(`[Heartbeat] Max failures reached (${MAX_FAILURES}), stopping`);
|
|
322
|
-
await cleanup();
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
} catch (error) {
|
|
326
|
-
heartbeatCount--; // Rollback count on error
|
|
327
|
-
failureCount++;
|
|
328
|
-
console.error(`[Heartbeat] Error sending heartbeat:`, error.message);
|
|
329
|
-
|
|
330
|
-
if (failureCount >= MAX_FAILURES) {
|
|
331
|
-
console.error(`[Heartbeat] Max failures reached (${MAX_FAILURES}), stopping`);
|
|
332
|
-
await cleanup();
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Cleanup and exit
|
|
339
|
-
*/
|
|
340
|
-
async function cleanup() {
|
|
341
|
-
console.log(`[Heartbeat] Cleaning up session ${SESSION_ID}`);
|
|
342
|
-
|
|
343
|
-
if (intervalHandle) {
|
|
344
|
-
clearInterval(intervalHandle);
|
|
345
|
-
intervalHandle = null;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Remove PID file
|
|
349
|
-
try {
|
|
350
|
-
await unlink(PID_FILE);
|
|
351
|
-
} catch (error) {
|
|
352
|
-
// Ignore errors - file might not exist
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
exit(0);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Start heartbeat loop
|
|
360
|
-
*/
|
|
361
|
-
async function start() {
|
|
362
|
-
try {
|
|
363
|
-
// Write PID file with session_id for validation (prevents killing wrong process)
|
|
364
|
-
await writeFile(PID_FILE, JSON.stringify({
|
|
365
|
-
pid: processPid,
|
|
366
|
-
session_id: SESSION_ID,
|
|
367
|
-
started_at: Date.now()
|
|
368
|
-
}), { mode: 0o600 });
|
|
369
|
-
|
|
370
|
-
if (DEBUG) {
|
|
371
|
-
console.log(`[Heartbeat] Started for session ${SESSION_ID} (PID: ${processPid})`);
|
|
372
|
-
console.log(`[Heartbeat] Interval: ${HEARTBEAT_INTERVAL}ms, Start delay: ${START_DELAY}ms, Max failures: ${MAX_FAILURES}, Fetch timeout: ${FETCH_TIMEOUT}ms`);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Wait for initial delay before first heartbeat
|
|
376
|
-
setTimeout(() => {
|
|
377
|
-
// Send first heartbeat immediately after delay
|
|
378
|
-
sendHeartbeat();
|
|
379
|
-
|
|
380
|
-
// Then set up interval for subsequent heartbeats
|
|
381
|
-
intervalHandle = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL);
|
|
382
|
-
}, START_DELAY);
|
|
383
|
-
|
|
384
|
-
// Handle graceful shutdown
|
|
385
|
-
process.on('SIGTERM', cleanup);
|
|
386
|
-
process.on('SIGINT', cleanup);
|
|
387
|
-
process.on('SIGHUP', cleanup);
|
|
388
|
-
|
|
389
|
-
} catch (error) {
|
|
390
|
-
console.error(`[Heartbeat] Failed to start:`, error.message);
|
|
391
|
-
exit(1);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Start the heartbeat process
|
|
396
|
-
start();
|
|
@@ -1,183 +0,0 @@
|
|
|
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
|
-
export const TELEPORTATION_DIR = join(homedir(), '.teleportation');
|
|
11
|
-
export 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
|
-
|
|
123
|
-
// Ensure .teleportation directory exists with strict permissions
|
|
124
|
-
await fs.mkdir(TELEPORTATION_DIR, { recursive: true, mode: 0o700 });
|
|
125
|
-
|
|
126
|
-
// 1. Attempt atomic acquisition with 'wx' (exclusive write)
|
|
127
|
-
try {
|
|
128
|
-
await fs.writeFile(PID_FILE, String(pid), { mode: 0o600, flag: 'wx' });
|
|
129
|
-
return;
|
|
130
|
-
} catch (err) {
|
|
131
|
-
if (err.code !== 'EEXIST') {
|
|
132
|
-
throw err;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// 2. Lock exists - verify if it's stale
|
|
136
|
-
const currentPid = await readPid();
|
|
137
|
-
|
|
138
|
-
if (currentPid === null) {
|
|
139
|
-
// Should not happen with atomic write, but handles concurrent removal
|
|
140
|
-
return await acquirePidLock(pid);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (currentPid === pid) {
|
|
144
|
-
// Self-recovery: we already own this lock
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (isProcessRunning(currentPid)) {
|
|
149
|
-
throw new Error(`Daemon already running with PID ${currentPid}`);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// 3. Process dead - stale lock. Clean up and retry.
|
|
153
|
-
console.log(`[pid-manager] Stale PID file found (${currentPid}), cleaning up and retrying...`);
|
|
154
|
-
await removePid();
|
|
155
|
-
return await acquirePidLock(pid);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Release PID lock (remove PID file if it matches our PID)
|
|
161
|
-
* @param {number} pid - Our process ID
|
|
162
|
-
*/
|
|
163
|
-
export async function releasePidLock(pid) {
|
|
164
|
-
console.log(`[pid-manager] Releasing PID lock for PID ${pid}...`);
|
|
165
|
-
const currentPid = await readPid();
|
|
166
|
-
|
|
167
|
-
// Only remove if PID file matches our process
|
|
168
|
-
if (currentPid === pid) {
|
|
169
|
-
await removePid();
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export default {
|
|
174
|
-
isProcessRunning,
|
|
175
|
-
readPid,
|
|
176
|
-
writePid,
|
|
177
|
-
removePid,
|
|
178
|
-
checkDaemonStatus,
|
|
179
|
-
cleanupStalePid,
|
|
180
|
-
acquirePidLock,
|
|
181
|
-
releasePidLock,
|
|
182
|
-
PID_FILE
|
|
183
|
-
};
|