teleportation-cli 1.1.4 → 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/config-loader.mjs +88 -34
- package/.claude/hooks/permission_request.mjs +392 -82
- package/.claude/hooks/post_tool_use.mjs +90 -0
- package/.claude/hooks/pre_tool_use.mjs +247 -305
- package/.claude/hooks/session-register.mjs +94 -105
- 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/README.md +7 -0
- package/lib/auth/api-key.js +12 -0
- package/lib/auth/token-refresh.js +286 -0
- 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/response-classifier.js +15 -1
- 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 +1235 -0
- package/lib/daemon/teleportation-daemon.js +770 -25
- 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 +11 -5
- package/teleportation-cli.cjs +632 -451
- package/.claude/hooks/heartbeat.mjs +0 -396
- package/lib/daemon/agentic-executor.js +0 -803
- package/lib/daemon/pid-manager.js +0 -160
|
@@ -38,8 +38,26 @@ import http from 'http';
|
|
|
38
38
|
import { fileURLToPath } from 'url';
|
|
39
39
|
import { spawn, exec } from 'child_process';
|
|
40
40
|
import { promisify } from 'util';
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
41
|
+
import { homedir, tmpdir } from 'os';
|
|
42
|
+
import { existsSync, appendFileSync } from 'fs';
|
|
43
|
+
import { join } from 'path';
|
|
44
|
+
// NOTE: PID locking is handled by agent-process at the platform level (launchd/systemd/pm2).
|
|
45
|
+
// Signal handling and heartbeat management are handled inline below.
|
|
46
|
+
// The following were removed in PRD-0025 migration:
|
|
47
|
+
// - pid-manager.js (replaced by agent-process platform locking)
|
|
48
|
+
// - lifecycle.js (replaced by inline signal handlers)
|
|
49
|
+
// - heartbeat-manager.js (replaced by inline heartbeat interval)
|
|
50
|
+
|
|
51
|
+
// Task executor for autonomous tasks (PRD-0016)
|
|
52
|
+
// V2 (timeline-driven stateless executor)
|
|
53
|
+
import {
|
|
54
|
+
executeTaskTurn,
|
|
55
|
+
stopTask,
|
|
56
|
+
stopAllTasks,
|
|
57
|
+
} from './task-executor-v2.js';
|
|
58
|
+
|
|
59
|
+
// Transcript ingestion for timeline completeness
|
|
60
|
+
import { ingestTranscriptToTimeline } from './transcript-ingestion.js';
|
|
43
61
|
|
|
44
62
|
// Machine coder adapters for multi-provider support
|
|
45
63
|
import { getAvailableCoders, getBestCoder } from '../machine-coders/index.js';
|
|
@@ -51,12 +69,15 @@ import { createRouter, classifyTask } from '../router/index.js';
|
|
|
51
69
|
import { logError, logWarn, logInfo, logDebug, logVerbose } from '../utils/logger.js';
|
|
52
70
|
import { sanitizeForLog, truncateForLog, prepareForLog } from '../utils/log-sanitizer.js';
|
|
53
71
|
|
|
72
|
+
// Credential management for loading API keys
|
|
73
|
+
import { CredentialManager } from '../auth/credentials.js';
|
|
74
|
+
|
|
54
75
|
const execAsync = promisify(exec);
|
|
55
76
|
console.log('[daemon] Starting up...');
|
|
56
77
|
|
|
57
78
|
const PORT = parseInt(process.env.TELEPORTATION_DAEMON_PORT || '3050', 10);
|
|
58
|
-
|
|
59
|
-
|
|
79
|
+
let RELAY_API_URL = process.env.RELAY_API_URL || 'https://api.teleportation.dev';
|
|
80
|
+
let RELAY_API_KEY = process.env.RELAY_API_KEY || '';
|
|
60
81
|
const POLL_INTERVAL_MS = parseInt(process.env.DAEMON_POLL_INTERVAL_MS || '5000', 10);
|
|
61
82
|
const CHILD_TIMEOUT_MS = parseInt(process.env.DAEMON_CHILD_TIMEOUT_MS || '600000', 10); // 10 min
|
|
62
83
|
const IDLE_CHECK_INTERVAL_MS = parseInt(process.env.DAEMON_IDLE_CHECK_INTERVAL_MS || '300000', 10); // 5 min
|
|
@@ -64,6 +85,7 @@ const IDLE_TIMEOUT_MS = parseInt(process.env.DAEMON_IDLE_TIMEOUT_MS || '1800000'
|
|
|
64
85
|
const CLAUDE_CLI = process.env.CLAUDE_CLI_PATH || 'claude'; // Configurable Claude CLI path
|
|
65
86
|
const ALLOW_ALL_COMMANDS = process.env.TELEPORTATION_DAEMON_ALLOW_ALL_COMMANDS === 'true';
|
|
66
87
|
const HEARTBEAT_INTERVAL_MS = parseInt(process.env.DAEMON_HEARTBEAT_INTERVAL_MS || '30000', 10); // 30 sec default
|
|
88
|
+
const HEARTBEAT_CHECK_INTERVAL_MS = parseInt(process.env.DAEMON_HEARTBEAT_CHECK_INTERVAL_MS || '60000', 10); // 1 min default
|
|
67
89
|
|
|
68
90
|
// Message routing configuration
|
|
69
91
|
// REQUIRE_COMMAND_WHITELIST: If true, use legacy shell execution with command whitelist
|
|
@@ -84,6 +106,29 @@ const ROUTER_ENABLED = process.env.TELEPORTATION_ROUTER_ENABLED !== 'false' && !
|
|
|
84
106
|
const ROUTER_VERBOSE = process.env.TELEPORTATION_ROUTER_VERBOSE === 'true';
|
|
85
107
|
const ROUTER_MAX_ESCALATIONS = parseInt(process.env.TELEPORTATION_ROUTER_MAX_ESCALATIONS || '2', 10);
|
|
86
108
|
|
|
109
|
+
// Debug logging configuration
|
|
110
|
+
const DEBUG = process.env.TELEPORTATION_DEBUG === 'true';
|
|
111
|
+
const LOG_DIR = process.env.TELEPORTATION_LOG_DIR || tmpdir();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Cross-platform debug logging utility
|
|
115
|
+
* Only logs when TELEPORTATION_DEBUG=true
|
|
116
|
+
* Uses os.tmpdir() for cross-platform compatibility
|
|
117
|
+
* @param {string} filename - Log file name (e.g., 'daemon-poll-debug.log')
|
|
118
|
+
* @param {string} message - Message to log
|
|
119
|
+
*/
|
|
120
|
+
function debugLog(filename, message) {
|
|
121
|
+
if (!DEBUG) return;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const logPath = join(LOG_DIR, filename);
|
|
125
|
+
appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// Silently fail - debug logging should never crash the daemon
|
|
128
|
+
console.error(`[daemon] Failed to write debug log: ${error.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
87
132
|
// Test helper: allows mocking executeWithMachineCoder in tests
|
|
88
133
|
// In production, this just holds null and the real function is called
|
|
89
134
|
const _executeWithMachineCoderRef = { fn: null };
|
|
@@ -184,9 +229,59 @@ const OUTPUT_PREVIEW_LONG = 1000; // Full output displays
|
|
|
184
229
|
const heartbeatState = new Map();
|
|
185
230
|
let lastHeartbeatTime = 0;
|
|
186
231
|
|
|
187
|
-
//
|
|
232
|
+
// In-memory registry of active teleportation sessions handled by this daemon
|
|
188
233
|
const sessions = new Map();
|
|
189
234
|
|
|
235
|
+
// Track sessions that have been explicitly stopped and should not be re-activated
|
|
236
|
+
const stoppedSessions = new Set();
|
|
237
|
+
|
|
238
|
+
// Session activity tracking for cleanup
|
|
239
|
+
const sessionActivity = new Map(); // sessionId -> lastActivityTimestamp
|
|
240
|
+
|
|
241
|
+
// Transcript ingestion throttling: Prevents concurrent ingestion runs per session
|
|
242
|
+
// Map<session_id, Promise> tracks in-progress ingestion promises
|
|
243
|
+
// If ingestion takes >5 seconds, prevents stacking multiple concurrent calls
|
|
244
|
+
const ingestionInProgress = new Map();
|
|
245
|
+
|
|
246
|
+
// Session cleanup configuration
|
|
247
|
+
const SESSION_HEARTBEAT_INTERVAL_MS = 120000; // 2 minutes (should match hook config)
|
|
248
|
+
const SESSION_TIMEOUT_MS = SESSION_HEARTBEAT_INTERVAL_MS * 2; // 4 minutes (2 missed heartbeats)
|
|
249
|
+
const SESSION_CLEANUP_INTERVAL_MS = 60000; // Check every minute
|
|
250
|
+
const LOG_COMPACT_INTERVAL_MS = 300000; // Compact log every 5 minutes
|
|
251
|
+
|
|
252
|
+
// Periodically cleanup stale sessions from memory
|
|
253
|
+
// Sessions with no heartbeat in 4+ minutes are considered dead
|
|
254
|
+
// Note: With timeline-driven architecture (V2), we no longer track task state in memory.
|
|
255
|
+
// Task state is derived from timeline events, making the system stateless and crash-resistant.
|
|
256
|
+
setInterval(() => {
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
let sessionCleanedCount = 0;
|
|
259
|
+
|
|
260
|
+
for (const [sessionId, lastActivity] of sessionActivity) {
|
|
261
|
+
const timeSinceActivity = now - lastActivity;
|
|
262
|
+
|
|
263
|
+
// Remove sessions with no activity in SESSION_TIMEOUT_MS (4 minutes)
|
|
264
|
+
if (timeSinceActivity > SESSION_TIMEOUT_MS) {
|
|
265
|
+
sessions.delete(sessionId);
|
|
266
|
+
sessionActivity.delete(sessionId);
|
|
267
|
+
heartbeatState.delete(sessionId);
|
|
268
|
+
sessionCleanedCount++;
|
|
269
|
+
|
|
270
|
+
if (process.env.DEBUG) {
|
|
271
|
+
console.log(`[daemon] Expired session ${sessionId.slice(0, 8)}... (no heartbeat for ${Math.floor(timeSinceActivity / 60000)} minutes)`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (sessionCleanedCount > 0) {
|
|
277
|
+
console.log(`[daemon] Cleaned up ${sessionCleanedCount} stale session(s) (no heartbeat > ${SESSION_TIMEOUT_MS / 60000} min)`);
|
|
278
|
+
}
|
|
279
|
+
}, SESSION_CLEANUP_INTERVAL_MS);
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Sync daemon state to relay API
|
|
283
|
+
*/
|
|
284
|
+
|
|
190
285
|
// Approval queue: FIFO queue of pending approvals
|
|
191
286
|
// { approval_id, session_id, tool_name, tool_input, queued_at }
|
|
192
287
|
const approvalQueue = [];
|
|
@@ -354,6 +449,8 @@ let server = null;
|
|
|
354
449
|
let pollingTimer = null;
|
|
355
450
|
let cleanupTimer = null;
|
|
356
451
|
let idleTimer = null;
|
|
452
|
+
let logCompactTimer = null;
|
|
453
|
+
let heartbeatTimer = null;
|
|
357
454
|
let isShuttingDown = false;
|
|
358
455
|
|
|
359
456
|
// Track last time we had any registered sessions (or last time we checked while sessions were present)
|
|
@@ -541,6 +638,38 @@ function validateToolName(tool_name) {
|
|
|
541
638
|
return tool_name;
|
|
542
639
|
}
|
|
543
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Check if a Claude Code session exists locally.
|
|
643
|
+
* Claude Code stores sessions in ~/.claude/projects/<project-path-encoded>/<session-id>.jsonl
|
|
644
|
+
*
|
|
645
|
+
* @param {string} sessionId - The session ID (UUID format)
|
|
646
|
+
* @param {string} [cwd] - The working directory to derive the project path
|
|
647
|
+
* @returns {boolean} - True if the session file exists locally
|
|
648
|
+
*/
|
|
649
|
+
function claudeSessionExistsLocally(sessionId, cwd) {
|
|
650
|
+
if (!sessionId) return false;
|
|
651
|
+
|
|
652
|
+
// Validate UUID format
|
|
653
|
+
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
654
|
+
if (!UUID_V4_PATTERN.test(sessionId)) return false;
|
|
655
|
+
|
|
656
|
+
// Normalize path separators for cross-platform support
|
|
657
|
+
// Windows uses backslashes (C:\Users\...), Unix uses forward slashes (/Users/...)
|
|
658
|
+
// Claude Code encodes the project path by:
|
|
659
|
+
// 1. Normalizing Windows backslashes to forward slashes
|
|
660
|
+
// 2. Replacing both / and _ with -
|
|
661
|
+
// e.g., C:\Users\project -> C:-Users-project
|
|
662
|
+
// e.g., /Users/kefentse/dev_env -> -Users-kefentse-dev-env
|
|
663
|
+
const projectPath = cwd || process.cwd();
|
|
664
|
+
const normalizedPath = projectPath.replace(/\\/g, '/'); // Windows to Unix
|
|
665
|
+
const encodedPath = normalizedPath.replace(/[/_]/g, '-');
|
|
666
|
+
|
|
667
|
+
const claudeProjectsDir = join(homedir(), '.claude', 'projects');
|
|
668
|
+
const sessionFile = join(claudeProjectsDir, encodedPath, `${sessionId}.jsonl`);
|
|
669
|
+
|
|
670
|
+
return existsSync(sessionFile);
|
|
671
|
+
}
|
|
672
|
+
|
|
544
673
|
// Helper to send JSON response
|
|
545
674
|
function sendJSON(res, statusCode, data) {
|
|
546
675
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
@@ -599,6 +728,60 @@ async function handleRequest(req, res) {
|
|
|
599
728
|
return;
|
|
600
729
|
}
|
|
601
730
|
|
|
731
|
+
// Check if session has been explicitly stopped BY THIS DAEMON and refuse to re-activate
|
|
732
|
+
// Note: We only block sessions stopped by THIS daemon process, not previous daemon instances
|
|
733
|
+
// This allows sessions to re-register after daemon restarts
|
|
734
|
+
if (stoppedSessions.has(session_id)) {
|
|
735
|
+
console.warn(`[daemon] Refusing to re-activate stopped session: ${session_id}`);
|
|
736
|
+
sendJSON(res, 403, {
|
|
737
|
+
error: 'session_stopped',
|
|
738
|
+
message: 'This session has ended and cannot be re-activated'
|
|
739
|
+
});
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Check if session was marked "stopped" in Redis by a previous daemon
|
|
744
|
+
// If so, clear that state and allow re-registration
|
|
745
|
+
// This handles daemon restarts gracefully - if Claude Code is still alive and trying to
|
|
746
|
+
// register, we should honor that even if a previous daemon marked it as stopped
|
|
747
|
+
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
748
|
+
try {
|
|
749
|
+
const stateRes = await fetch(
|
|
750
|
+
`${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/daemon-state`,
|
|
751
|
+
{
|
|
752
|
+
headers: {
|
|
753
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
754
|
+
},
|
|
755
|
+
signal: AbortSignal.timeout(3000)
|
|
756
|
+
}
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
if (stateRes.ok) {
|
|
760
|
+
const state = await stateRes.json();
|
|
761
|
+
if (state.daemon_state?.status === 'stopped') {
|
|
762
|
+
console.log(`[daemon] Session ${session_id} was marked stopped, but Claude Code is still alive - clearing stopped state`);
|
|
763
|
+
// Clear the stopped state in Redis
|
|
764
|
+
await fetch(
|
|
765
|
+
`${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/daemon-state`,
|
|
766
|
+
{
|
|
767
|
+
method: 'POST',
|
|
768
|
+
headers: {
|
|
769
|
+
'Content-Type': 'application/json',
|
|
770
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
771
|
+
},
|
|
772
|
+
body: JSON.stringify({ status: 'active' }),
|
|
773
|
+
signal: AbortSignal.timeout(3000)
|
|
774
|
+
}
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
} catch (err) {
|
|
779
|
+
// If we can't check/clear the state, log warning but allow registration
|
|
780
|
+
// This prevents relay API outages from blocking all session registrations
|
|
781
|
+
console.warn(`[daemon] Failed to check/clear daemon_state for ${session_id}: ${err.message}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
602
785
|
sessions.set(session_id, {
|
|
603
786
|
session_id,
|
|
604
787
|
claude_session_id: claude_session_id || session_id, // Fallback to session_id if not provided
|
|
@@ -710,6 +893,8 @@ async function handleRequest(req, res) {
|
|
|
710
893
|
}
|
|
711
894
|
|
|
712
895
|
function hasIdleTimedOut(now, lastActivityAt, timeoutMs, sessionCount) {
|
|
896
|
+
// timeoutMs === 0 disables idle timeout
|
|
897
|
+
if (timeoutMs <= 0) return false;
|
|
713
898
|
if (sessionCount > 0) return false;
|
|
714
899
|
return now - lastActivityAt >= timeoutMs;
|
|
715
900
|
}
|
|
@@ -812,6 +997,80 @@ async function executeCommand(session_id, command) {
|
|
|
812
997
|
}
|
|
813
998
|
}
|
|
814
999
|
|
|
1000
|
+
/**
|
|
1001
|
+
* Helper: Check if user is away for a given session
|
|
1002
|
+
* Returns { isAway: boolean, state: object | null }
|
|
1003
|
+
*/
|
|
1004
|
+
async function checkIsAwayState(session_id) {
|
|
1005
|
+
try {
|
|
1006
|
+
const stateResponse = await fetch(
|
|
1007
|
+
`${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/daemon-state`,
|
|
1008
|
+
{
|
|
1009
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` },
|
|
1010
|
+
signal: AbortSignal.timeout(3000)
|
|
1011
|
+
}
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
if (stateResponse.ok) {
|
|
1015
|
+
const daemonState = await stateResponse.json();
|
|
1016
|
+
return {
|
|
1017
|
+
isAway: daemonState.is_away === true,
|
|
1018
|
+
state: daemonState
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return { isAway: null, state: null };
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
logWarn(`[daemon] Failed to check is_away state: ${error.message}`);
|
|
1025
|
+
return { isAway: null, state: null };
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Helper: Acknowledge message and log handoff cancellation
|
|
1031
|
+
*/
|
|
1032
|
+
async function ackAndLogCancellation(session_id, message, triggeredBy = {}) {
|
|
1033
|
+
const toolName = triggeredBy.tool_name || 'the tool';
|
|
1034
|
+
const approvalId = triggeredBy.approval_id || 'unknown';
|
|
1035
|
+
|
|
1036
|
+
// Acknowledge message to prevent redelivery
|
|
1037
|
+
try {
|
|
1038
|
+
await fetch(`${RELAY_API_URL}/api/messages/${encodeURIComponent(message.id)}/ack`, {
|
|
1039
|
+
method: 'POST',
|
|
1040
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
1041
|
+
});
|
|
1042
|
+
logDebug(`[daemon] Acknowledged message ${message.id}`);
|
|
1043
|
+
} catch (ackError) {
|
|
1044
|
+
logWarn(`[daemon] Failed to acknowledge message ${message.id}: ${ackError.message}`);
|
|
1045
|
+
// Continue to log cancellation event even if ack fails
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Log handoff cancellation
|
|
1049
|
+
try {
|
|
1050
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
1051
|
+
method: 'POST',
|
|
1052
|
+
headers: {
|
|
1053
|
+
'Content-Type': 'application/json',
|
|
1054
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
1055
|
+
},
|
|
1056
|
+
body: JSON.stringify({
|
|
1057
|
+
session_id,
|
|
1058
|
+
type: 'handoff_cancelled',
|
|
1059
|
+
data: {
|
|
1060
|
+
reason: 'user_returned_locally',
|
|
1061
|
+
approval_id: approvalId,
|
|
1062
|
+
tool_name: toolName,
|
|
1063
|
+
message: `Auto-continue cancelled: user returned to local session after approving ${toolName} from mobile.`,
|
|
1064
|
+
timestamp: Date.now()
|
|
1065
|
+
}
|
|
1066
|
+
})
|
|
1067
|
+
});
|
|
1068
|
+
logInfo(`[daemon] 📝 Logged handoff_cancelled event to timeline`);
|
|
1069
|
+
} catch (notifyError) {
|
|
1070
|
+
logWarn(`[daemon] Failed to log handoff event: ${notifyError.message}`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
815
1074
|
async function handleInboxMessage(session_id, message) {
|
|
816
1075
|
try {
|
|
817
1076
|
const sanitizedPreview = prepareForLog(message.text || '', 200).replace(/\s+/g, ' ');
|
|
@@ -820,6 +1079,41 @@ async function handleInboxMessage(session_id, message) {
|
|
|
820
1079
|
logDebug(`[daemon] ↳ Preview: ${sanitizedPreview}`);
|
|
821
1080
|
|
|
822
1081
|
const meta = message.meta || {};
|
|
1082
|
+
const messageContext = message.context || meta.context;
|
|
1083
|
+
|
|
1084
|
+
// Check if this is an auto-continue message (from approval handoff)
|
|
1085
|
+
// If the user came back (is_away: false), skip auto-continue messages
|
|
1086
|
+
// to avoid executing while the user is active locally
|
|
1087
|
+
//
|
|
1088
|
+
// P0 FIX: TOCTOU Race Condition Protection
|
|
1089
|
+
// We check is_away TWICE:
|
|
1090
|
+
// 1. Here (initial check) - fast fail if user is already back
|
|
1091
|
+
// 2. Right before execution - prevents race where user returns during setup
|
|
1092
|
+
if (messageContext === 'approval_continue' || message.source === 'auto') {
|
|
1093
|
+
// FIRST CHECK: Initial is_away validation
|
|
1094
|
+
const { isAway: initialIsAway } = await checkIsAwayState(session_id);
|
|
1095
|
+
|
|
1096
|
+
// If we can't determine state or user is not away, skip for safety
|
|
1097
|
+
if (initialIsAway === null) {
|
|
1098
|
+
logWarn(`[daemon] Failed to check is_away state, skipping auto-continue for safety`);
|
|
1099
|
+
await fetch(`${RELAY_API_URL}/api/messages/${encodeURIComponent(message.id)}/ack`, {
|
|
1100
|
+
method: 'POST',
|
|
1101
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
1102
|
+
}).catch(() => {});
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (!initialIsAway) {
|
|
1107
|
+
logInfo(`[daemon] 🚫 Skipping auto-continue: user is back (is_away: false)`);
|
|
1108
|
+
logDebug(`[daemon] ↳ Message ID: ${message.id}`);
|
|
1109
|
+
|
|
1110
|
+
const triggeredBy = message.triggered_by || meta.triggered_by || {};
|
|
1111
|
+
await ackAndLogCancellation(session_id, message, triggeredBy);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
logInfo(`[daemon] ✅ Initial check passed: user is away, preparing auto-continue`);
|
|
1116
|
+
}
|
|
823
1117
|
|
|
824
1118
|
// For command messages, execute the command and post result back to the main agent inbox
|
|
825
1119
|
if (meta.type === 'command') {
|
|
@@ -898,11 +1192,57 @@ async function handleInboxMessage(session_id, message) {
|
|
|
898
1192
|
const sanitizedCommand = prepareForLog(commandText, 100);
|
|
899
1193
|
logInfo(`[daemon] 🚀 Routing message to Claude Code: ${sanitizedCommand}`);
|
|
900
1194
|
|
|
1195
|
+
// Check for handoff metadata - allows mobile to continue local sessions
|
|
1196
|
+
// The handoff object contains claude_session_id and cwd needed for resumption
|
|
1197
|
+
const handoffInfo = message.handoff || meta.handoff;
|
|
1198
|
+
if (handoffInfo && handoffInfo.use_resume && handoffInfo.claude_session_id) {
|
|
1199
|
+
// Update session with handoff information for proper resumption
|
|
1200
|
+
const session = sessions.get(session_id);
|
|
1201
|
+
if (session) {
|
|
1202
|
+
session.claude_session_id = handoffInfo.claude_session_id;
|
|
1203
|
+
if (handoffInfo.cwd) {
|
|
1204
|
+
session.cwd = handoffInfo.cwd;
|
|
1205
|
+
}
|
|
1206
|
+
logInfo(`[daemon] 🔄 Handoff session update: will resume ${handoffInfo.claude_session_id}`);
|
|
1207
|
+
logDebug(`[daemon] ↳ CWD: ${handoffInfo.cwd || 'unchanged'}`);
|
|
1208
|
+
} else {
|
|
1209
|
+
// Session not in local cache - register it with handoff info
|
|
1210
|
+
sessions.set(session_id, {
|
|
1211
|
+
session_id,
|
|
1212
|
+
claude_session_id: handoffInfo.claude_session_id,
|
|
1213
|
+
cwd: handoffInfo.cwd || process.cwd(),
|
|
1214
|
+
meta: {},
|
|
1215
|
+
registered_at: Date.now()
|
|
1216
|
+
});
|
|
1217
|
+
// Track activity for cleanup (prevents memory leak)
|
|
1218
|
+
sessionActivity.set(session_id, Date.now());
|
|
1219
|
+
logInfo(`[daemon] 🔄 Handoff: registered new session for resumption: ${handoffInfo.claude_session_id}`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
901
1223
|
// Stream output callback for message execution
|
|
902
1224
|
const onOutput = createStreamingCallback(session_id, message.id, {
|
|
903
1225
|
message_id: message.id
|
|
904
1226
|
});
|
|
905
1227
|
|
|
1228
|
+
// SECOND CHECK: Final is_away validation before execution
|
|
1229
|
+
// This prevents race condition where user returns during setup phase
|
|
1230
|
+
// (between the initial check and now)
|
|
1231
|
+
if (messageContext === 'approval_continue' || message.source === 'auto') {
|
|
1232
|
+
const { isAway: finalIsAway } = await checkIsAwayState(session_id);
|
|
1233
|
+
|
|
1234
|
+
if (finalIsAway === null || !finalIsAway) {
|
|
1235
|
+
logWarn(`[daemon] ⚠️ User returned during setup, aborting auto-continue`);
|
|
1236
|
+
const triggeredBy = message.triggered_by || meta.triggered_by || {};
|
|
1237
|
+
await ackAndLogCancellation(session_id, message, triggeredBy);
|
|
1238
|
+
|
|
1239
|
+
// Important: Don't proceed with execution
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
logDebug(`[daemon] ✅ Final check passed: user still away, proceeding with execution`);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
906
1246
|
// Use the unified machine coder interface
|
|
907
1247
|
// This supports Claude Code, Gemini CLI, and future backends
|
|
908
1248
|
// Note: _executeWithMachineCoderRef.fn is used for test mocking
|
|
@@ -1121,18 +1461,29 @@ async function sendHeartbeat(session_id) {
|
|
|
1121
1461
|
* Polls relay API every 5 seconds for approved requests
|
|
1122
1462
|
*/
|
|
1123
1463
|
async function pollRelayAPI() {
|
|
1464
|
+
// Debug: Mark that polling started
|
|
1465
|
+
debugLog('daemon-poll-debug.log', `pollRelayAPI() called, isShuttingDown=${isShuttingDown}`);
|
|
1466
|
+
|
|
1124
1467
|
if (isShuttingDown) return;
|
|
1125
1468
|
|
|
1126
1469
|
try {
|
|
1127
1470
|
// Fetch pending approvals and inbox messages for all registered sessions
|
|
1128
1471
|
const TEST_SESSION_FILTER = process.env.TELEPORTATION_TEST_SESSION_FILTER;
|
|
1129
|
-
|
|
1472
|
+
debugLog('daemon-poll-debug.log', `sessions.size=${sessions.size}`);
|
|
1473
|
+
|
|
1474
|
+
for (const [session_id, sessionData] of sessions) {
|
|
1130
1475
|
// Optional: Filter sessions for testing (if TEST_SESSION_FILTER env var set)
|
|
1131
1476
|
if (TEST_SESSION_FILTER && !session_id.startsWith(TEST_SESSION_FILTER)) {
|
|
1132
1477
|
continue;
|
|
1133
1478
|
}
|
|
1134
1479
|
console.log(`Polling for session ${session_id}`);
|
|
1135
1480
|
|
|
1481
|
+
// Debug: Log to file for visibility
|
|
1482
|
+
debugLog('daemon-poll-debug.log', `Polling session ${session_id}`);
|
|
1483
|
+
|
|
1484
|
+
// Update activity timestamp for cleanup tracking
|
|
1485
|
+
sessionActivity.set(session_id, Date.now());
|
|
1486
|
+
|
|
1136
1487
|
// 1) Approvals polling (existing behavior)
|
|
1137
1488
|
try {
|
|
1138
1489
|
const response = await fetch(
|
|
@@ -1158,6 +1509,13 @@ async function pollRelayAPI() {
|
|
|
1158
1509
|
// Skip if already acknowledged (already handled by hook's fast path)
|
|
1159
1510
|
if (approval.acknowledgedAt) continue;
|
|
1160
1511
|
|
|
1512
|
+
// Skip if decision was made from mobile or local (handled by hooks, not daemon)
|
|
1513
|
+
// Daemon should only execute approvals without decision_location (timeout cases)
|
|
1514
|
+
if (approval.decision_location) {
|
|
1515
|
+
console.log(`[daemon] Skipping approval ${approval.id} - decided by ${approval.decision_location} (hooks will execute)`);
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1161
1519
|
approvalQueue.push({
|
|
1162
1520
|
approval_id: approval.id,
|
|
1163
1521
|
session_id: approval.session_id,
|
|
@@ -1203,12 +1561,122 @@ async function pollRelayAPI() {
|
|
|
1203
1561
|
console.error(`[daemon] Inbox polling error for session ${session_id}:`, inboxError.message);
|
|
1204
1562
|
}
|
|
1205
1563
|
|
|
1206
|
-
// 3)
|
|
1564
|
+
// 3) Tasks polling (PRD-0016) - Timeline-Driven V2
|
|
1565
|
+
//
|
|
1566
|
+
// ARCHITECTURE: Stateless, Timeline-Driven Execution
|
|
1567
|
+
// ------------------------------------------------
|
|
1568
|
+
// The daemon has NO in-memory task state. Instead, it:
|
|
1569
|
+
// 1. Polls Redis for tasks
|
|
1570
|
+
// 2. Calls executeTaskTurn() which queries timeline
|
|
1571
|
+
// 3. Timeline analysis determines what to do next
|
|
1572
|
+
// 4. Executes one turn if ready, or skips if waiting
|
|
1573
|
+
//
|
|
1574
|
+
// Benefits:
|
|
1575
|
+
// - Crash-resistant: Daemon can restart without losing state
|
|
1576
|
+
// - No state drift: Timeline is source of truth
|
|
1577
|
+
// - Idempotent: Can retry execution safely
|
|
1578
|
+
// - Concurrent-safe: Multiple daemons could process tasks
|
|
1579
|
+
//
|
|
1580
|
+
// See: docs/TIMELINE_DRIVEN_TASK_EXECUTION.md
|
|
1581
|
+
try {
|
|
1582
|
+
const tasksResponse = await fetch(
|
|
1583
|
+
`${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/tasks`,
|
|
1584
|
+
{
|
|
1585
|
+
headers: {
|
|
1586
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
);
|
|
1590
|
+
|
|
1591
|
+
if (tasksResponse.ok) {
|
|
1592
|
+
const tasks = await tasksResponse.json();
|
|
1593
|
+
|
|
1594
|
+
// Process each task (stateless - queries timeline each time)
|
|
1595
|
+
for (const task of tasks) {
|
|
1596
|
+
// Skip stopped/completed tasks
|
|
1597
|
+
if (task.status === 'stopped' || task.status === 'completed') {
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
try {
|
|
1602
|
+
// Execute one turn (stateless - will check timeline and decide what to do)
|
|
1603
|
+
const result = await executeTaskTurn({
|
|
1604
|
+
task_id: task.id,
|
|
1605
|
+
session_id: task.session_id,
|
|
1606
|
+
config: {
|
|
1607
|
+
relayApiUrl: RELAY_API_URL,
|
|
1608
|
+
apiKey: RELAY_API_KEY
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
if (result.executed) {
|
|
1613
|
+
console.log(`[daemon] ✅ Task ${task.id.slice(0, 20)}... executed turn ${result.turn_count}`);
|
|
1614
|
+
} else if (result.waiting) {
|
|
1615
|
+
console.log(`[daemon] ⏸️ Task ${task.id.slice(0, 20)}... waiting: ${result.reason}`);
|
|
1616
|
+
} else if (result.stopped) {
|
|
1617
|
+
console.log(`[daemon] 🛑 Task ${task.id.slice(0, 20)}... stopped: ${result.reason}`);
|
|
1618
|
+
} else if (result.error) {
|
|
1619
|
+
console.error(`[daemon] ❌ Task ${task.id.slice(0, 20)}... error: ${result.error}`);
|
|
1620
|
+
}
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
console.error(`[daemon] Task execution error for ${task.id}:`, error.message);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
} catch (taskError) {
|
|
1627
|
+
console.error(`[daemon] Task polling error for session ${session_id}:`, taskError.message);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// 4) Transcript ingestion - backup to stop hook for timeline completeness
|
|
1631
|
+
// Non-blocking: Fire-and-forget to avoid blocking the polling loop
|
|
1632
|
+
// Processes only the most recent 100 events to stay fast
|
|
1633
|
+
const claude_session_id = sessionData.claude_session_id || session_id;
|
|
1634
|
+
const cwd = sessionData.cwd || process.cwd();
|
|
1635
|
+
|
|
1636
|
+
// Throttling: Check if ingestion is already in progress for this session
|
|
1637
|
+
// Prevents concurrent ingestion runs that could cause race conditions
|
|
1638
|
+
if (ingestionInProgress.has(session_id)) {
|
|
1639
|
+
debugLog('daemon-transcript-debug.log', `Ingestion already in progress for ${session_id}, skipping`);
|
|
1640
|
+
// Skip this polling cycle for this session
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// Debug marker
|
|
1645
|
+
debugLog('daemon-transcript-debug.log', `Starting ingestion for ${session_id}`);
|
|
1646
|
+
|
|
1647
|
+
// Start ingestion and track promise to prevent concurrent runs
|
|
1648
|
+
const ingestionPromise = ingestTranscriptToTimeline({
|
|
1649
|
+
claude_session_id,
|
|
1650
|
+
parent_session_id: session_id,
|
|
1651
|
+
task_id: null, // Not a task execution, just regular session
|
|
1652
|
+
cwd,
|
|
1653
|
+
config: {
|
|
1654
|
+
relayApiUrl: RELAY_API_URL,
|
|
1655
|
+
apiKey: RELAY_API_KEY
|
|
1656
|
+
},
|
|
1657
|
+
maxEvents: 100 // Limit to recent 100 events (prevents blocking on large backlogs)
|
|
1658
|
+
}).catch(transcriptError => {
|
|
1659
|
+
// Log errors but don't block the daemon
|
|
1660
|
+
console.error(`[daemon] ❌ Transcript ingestion error for session ${session_id}:`, transcriptError.message);
|
|
1661
|
+
if (process.env.DEBUG) {
|
|
1662
|
+
console.error(transcriptError.stack);
|
|
1663
|
+
}
|
|
1664
|
+
}).finally(() => {
|
|
1665
|
+
// Remove from tracking when done (success or failure)
|
|
1666
|
+
// This allows next polling cycle to run ingestion again
|
|
1667
|
+
ingestionInProgress.delete(session_id);
|
|
1668
|
+
debugLog('daemon-transcript-debug.log', `Ingestion completed for ${session_id}`);
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
// Track the promise (but don't await - fire-and-forget)
|
|
1672
|
+
ingestionInProgress.set(session_id, ingestionPromise);
|
|
1673
|
+
|
|
1674
|
+
// 5) Heartbeat - send periodically to keep session alive
|
|
1207
1675
|
// Only send heartbeat if enough time has passed since last one (throttled per session)
|
|
1208
1676
|
const now = Date.now();
|
|
1209
1677
|
const sessionHeartbeat = heartbeatState.get(session_id);
|
|
1210
1678
|
const lastSent = sessionHeartbeat?.lastSent || 0;
|
|
1211
|
-
if (now - lastSent >=
|
|
1679
|
+
if (now - lastSent >= SESSION_HEARTBEAT_INTERVAL_MS) {
|
|
1212
1680
|
await sendHeartbeat(session_id);
|
|
1213
1681
|
}
|
|
1214
1682
|
}
|
|
@@ -1714,18 +2182,51 @@ async function executeWithMachineCoder(session_id, prompt, options = {}) {
|
|
|
1714
2182
|
const startedAt = Date.now();
|
|
1715
2183
|
|
|
1716
2184
|
try {
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
|
|
2185
|
+
// Determine if we can resume an existing Claude Code session.
|
|
2186
|
+
// We can resume if:
|
|
2187
|
+
// 1. We have a claude_session_id (UUID)
|
|
2188
|
+
// 2. The corresponding session file exists locally (~/.claude/projects/<project>/<id>.jsonl)
|
|
2189
|
+
//
|
|
2190
|
+
// Note: Claude Code DOES use UUIDs for session IDs (confirmed via testing).
|
|
2191
|
+
// The session file check ensures we only resume valid local sessions,
|
|
2192
|
+
// not sessions created remotely (e.g., from mobile) that don't have local transcripts.
|
|
1722
2193
|
let result;
|
|
1723
|
-
const
|
|
1724
|
-
session.claude_session_id
|
|
1725
|
-
|
|
2194
|
+
const canResumeSession = session.claude_session_id &&
|
|
2195
|
+
claudeSessionExistsLocally(session.claude_session_id, session.cwd);
|
|
2196
|
+
|
|
2197
|
+
if (coder.name === 'claude-code' && canResumeSession) {
|
|
2198
|
+
try {
|
|
2199
|
+
console.log(`[daemon] Resuming existing Claude session: ${session.claude_session_id}`);
|
|
2200
|
+
result = await coder.resume(session.claude_session_id, prompt, execOptions);
|
|
2201
|
+
} catch (resumeError) {
|
|
2202
|
+
// Resume failed (corrupted session file, permissions, etc.) - fallback to fresh execution
|
|
2203
|
+
logWarn(`[daemon] Resume failed: ${resumeError.message}, falling back to fresh execution`);
|
|
1726
2204
|
|
|
1727
|
-
|
|
1728
|
-
|
|
2205
|
+
// Log timeline event for visibility
|
|
2206
|
+
try {
|
|
2207
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
2208
|
+
method: 'POST',
|
|
2209
|
+
headers: {
|
|
2210
|
+
'Content-Type': 'application/json',
|
|
2211
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
2212
|
+
},
|
|
2213
|
+
body: JSON.stringify({
|
|
2214
|
+
session_id,
|
|
2215
|
+
type: 'resume_fallback',
|
|
2216
|
+
source: 'system',
|
|
2217
|
+
data: {
|
|
2218
|
+
error: resumeError.message,
|
|
2219
|
+
claude_session_id: session.claude_session_id,
|
|
2220
|
+
cwd: session.cwd,
|
|
2221
|
+
timestamp: Date.now()
|
|
2222
|
+
}
|
|
2223
|
+
})
|
|
2224
|
+
}).catch(() => {}); // Ignore timeline logging failures
|
|
2225
|
+
} catch (ignore) {}
|
|
2226
|
+
|
|
2227
|
+
// Fallback to fresh execution
|
|
2228
|
+
result = await coder.execute(execOptions);
|
|
2229
|
+
}
|
|
1729
2230
|
} else {
|
|
1730
2231
|
result = await coder.execute(execOptions);
|
|
1731
2232
|
}
|
|
@@ -2090,6 +2591,13 @@ async function cleanup() {
|
|
|
2090
2591
|
console.log('[daemon] Cleaning up...');
|
|
2091
2592
|
isShuttingDown = true;
|
|
2092
2593
|
|
|
2594
|
+
// Stop all task tasks (PRD-0016)
|
|
2595
|
+
try {
|
|
2596
|
+
stopAllTasks();
|
|
2597
|
+
} catch (e) {
|
|
2598
|
+
console.error('[daemon] Error stopping task tasks during cleanup:', e.message);
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2093
2601
|
// Stop polling
|
|
2094
2602
|
if (pollingTimer) {
|
|
2095
2603
|
clearTimeout(pollingTimer);
|
|
@@ -2107,11 +2615,25 @@ async function cleanup() {
|
|
|
2107
2615
|
idleTimer = null;
|
|
2108
2616
|
}
|
|
2109
2617
|
|
|
2618
|
+
if (logCompactTimer) {
|
|
2619
|
+
clearInterval(logCompactTimer);
|
|
2620
|
+
logCompactTimer = null;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// Stop heartbeat timer
|
|
2624
|
+
if (heartbeatTimer) {
|
|
2625
|
+
clearInterval(heartbeatTimer);
|
|
2626
|
+
heartbeatTimer = null;
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2110
2629
|
// Update daemon_state to 'stopped' for all registered sessions
|
|
2111
2630
|
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
2112
2631
|
console.log(`[daemon] Updating daemon_state to 'stopped' for ${sessions.size} session(s)...`);
|
|
2113
2632
|
const updatePromises = [];
|
|
2114
2633
|
for (const [session_id] of sessions) {
|
|
2634
|
+
// Mark session as stopped to prevent re-activation
|
|
2635
|
+
stoppedSessions.add(session_id);
|
|
2636
|
+
|
|
2115
2637
|
updatePromises.push(
|
|
2116
2638
|
fetch(
|
|
2117
2639
|
`${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/daemon-state`,
|
|
@@ -2142,23 +2664,194 @@ async function cleanup() {
|
|
|
2142
2664
|
});
|
|
2143
2665
|
}
|
|
2144
2666
|
|
|
2145
|
-
//
|
|
2146
|
-
await releasePidLock(process.pid);
|
|
2667
|
+
// PID lock release is handled by agent-process at the platform level
|
|
2147
2668
|
|
|
2148
2669
|
console.log('[daemon] Cleanup complete');
|
|
2149
2670
|
}
|
|
2150
2671
|
|
|
2672
|
+
/**
|
|
2673
|
+
* Discover active sessions from shared log file
|
|
2674
|
+
* Enables daemon to recover sessions after restart
|
|
2675
|
+
*/
|
|
2676
|
+
async function discoverSessionsFromLog() {
|
|
2677
|
+
const SESSION_LOG_FILE = join(homedir(), '.teleportation', 'session-events.log');
|
|
2678
|
+
|
|
2679
|
+
try {
|
|
2680
|
+
const { readFile } = await import('fs/promises');
|
|
2681
|
+
const content = await readFile(SESSION_LOG_FILE, 'utf8');
|
|
2682
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
2683
|
+
|
|
2684
|
+
// Build session state by replaying events
|
|
2685
|
+
const sessionState = new Map();
|
|
2686
|
+
|
|
2687
|
+
for (const line of lines) {
|
|
2688
|
+
try {
|
|
2689
|
+
const event = JSON.parse(line);
|
|
2690
|
+
|
|
2691
|
+
switch (event.type) {
|
|
2692
|
+
case 'register':
|
|
2693
|
+
sessionState.set(event.session_id, {
|
|
2694
|
+
session_id: event.session_id,
|
|
2695
|
+
claude_session_id: event.claude_session_id || event.session_id,
|
|
2696
|
+
cwd: event.cwd || process.cwd(),
|
|
2697
|
+
meta: event.meta || {},
|
|
2698
|
+
pid: event.pid,
|
|
2699
|
+
last_heartbeat: event.timestamp,
|
|
2700
|
+
registered_at: event.timestamp
|
|
2701
|
+
});
|
|
2702
|
+
break;
|
|
2703
|
+
|
|
2704
|
+
case 'heartbeat':
|
|
2705
|
+
if (sessionState.has(event.session_id)) {
|
|
2706
|
+
const session = sessionState.get(event.session_id);
|
|
2707
|
+
session.last_heartbeat = event.timestamp;
|
|
2708
|
+
session.pid = event.pid; // Update PID in case process changed
|
|
2709
|
+
}
|
|
2710
|
+
break;
|
|
2711
|
+
|
|
2712
|
+
case 'unregister':
|
|
2713
|
+
sessionState.delete(event.session_id);
|
|
2714
|
+
break;
|
|
2715
|
+
}
|
|
2716
|
+
} catch (parseError) {
|
|
2717
|
+
// Skip malformed lines
|
|
2718
|
+
if (process.env.DEBUG) {
|
|
2719
|
+
console.error(`[daemon] Failed to parse log line: ${parseError.message}`);
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
// Only keep sessions with recent heartbeat
|
|
2725
|
+
// Use same timeout as runtime cleanup for consistency
|
|
2726
|
+
const cutoffTime = Date.now() - SESSION_TIMEOUT_MS;
|
|
2727
|
+
const activeSessions = Array.from(sessionState.values()).filter(
|
|
2728
|
+
s => s.last_heartbeat > cutoffTime
|
|
2729
|
+
);
|
|
2730
|
+
|
|
2731
|
+
// Add to daemon's session Map
|
|
2732
|
+
for (const session of activeSessions) {
|
|
2733
|
+
sessions.set(session.session_id, session);
|
|
2734
|
+
sessionActivity.set(session.session_id, session.last_heartbeat);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
if (activeSessions.length > 0) {
|
|
2738
|
+
console.log(`[daemon] 🔍 Discovered ${activeSessions.length} active session(s) from log`);
|
|
2739
|
+
for (const session of activeSessions) {
|
|
2740
|
+
console.log(`[daemon] - ${session.session_id.slice(0, 8)}... (PID: ${session.pid}, cwd: ${session.cwd})`);
|
|
2741
|
+
}
|
|
2742
|
+
} else {
|
|
2743
|
+
console.log(`[daemon] No active sessions found in log`);
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// Optional: Compact log to remove old entries
|
|
2747
|
+
await compactSessionLog(activeSessions);
|
|
2748
|
+
|
|
2749
|
+
} catch (error) {
|
|
2750
|
+
if (error.code === 'ENOENT') {
|
|
2751
|
+
console.log('[daemon] Session log file not found (first run)');
|
|
2752
|
+
} else {
|
|
2753
|
+
console.error(`[daemon] Failed to read session log: ${error.message}`);
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/**
|
|
2759
|
+
* Compact session log to remove old entries
|
|
2760
|
+
* Keeps only active sessions to prevent unbounded growth
|
|
2761
|
+
*/
|
|
2762
|
+
async function compactSessionLog(activeSessions) {
|
|
2763
|
+
const SESSION_LOG_FILE = join(homedir(), '.teleportation', 'session-events.log');
|
|
2764
|
+
|
|
2765
|
+
try {
|
|
2766
|
+
const { writeFile } = await import('fs/promises');
|
|
2767
|
+
|
|
2768
|
+
// Rewrite log with only active sessions
|
|
2769
|
+
const compactedLog = activeSessions.flatMap(session => [
|
|
2770
|
+
JSON.stringify({
|
|
2771
|
+
type: 'register',
|
|
2772
|
+
session_id: session.session_id,
|
|
2773
|
+
claude_session_id: session.claude_session_id,
|
|
2774
|
+
pid: session.pid,
|
|
2775
|
+
cwd: session.cwd,
|
|
2776
|
+
meta: session.meta,
|
|
2777
|
+
timestamp: session.registered_at
|
|
2778
|
+
}),
|
|
2779
|
+
JSON.stringify({
|
|
2780
|
+
type: 'heartbeat',
|
|
2781
|
+
session_id: session.session_id,
|
|
2782
|
+
claude_session_id: session.claude_session_id,
|
|
2783
|
+
pid: session.pid,
|
|
2784
|
+
cwd: session.cwd,
|
|
2785
|
+
timestamp: session.last_heartbeat
|
|
2786
|
+
})
|
|
2787
|
+
]).join('\n') + '\n';
|
|
2788
|
+
|
|
2789
|
+
await writeFile(SESSION_LOG_FILE, compactedLog, { mode: 0o600 });
|
|
2790
|
+
console.log(`[daemon] Compacted session log (${activeSessions.length} active sessions)`);
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
// Non-critical - log compaction can fail
|
|
2793
|
+
if (process.env.DEBUG) {
|
|
2794
|
+
console.error(`[daemon] Failed to compact session log: ${error.message}`);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2151
2799
|
/**
|
|
2152
2800
|
* Start daemon
|
|
2153
2801
|
*/
|
|
2154
2802
|
async function main() {
|
|
2155
2803
|
console.log('[daemon] Main function started.');
|
|
2804
|
+
|
|
2805
|
+
// Load credentials from encrypted file if not in environment
|
|
2806
|
+
if (!RELAY_API_KEY) {
|
|
2807
|
+
try {
|
|
2808
|
+
console.log('[daemon] RELAY_API_KEY not in environment, loading from credentials file...');
|
|
2809
|
+
const credManager = new CredentialManager();
|
|
2810
|
+
const creds = await credManager.load();
|
|
2811
|
+
if (creds && creds.apiKey) {
|
|
2812
|
+
RELAY_API_KEY = creds.apiKey;
|
|
2813
|
+
RELAY_API_URL = creds.relayUrl || RELAY_API_URL;
|
|
2814
|
+
console.log('[daemon] ✅ Loaded credentials from encrypted file');
|
|
2815
|
+
} else {
|
|
2816
|
+
console.warn('[daemon] ⚠️ No API key found in credentials file');
|
|
2817
|
+
}
|
|
2818
|
+
} catch (e) {
|
|
2819
|
+
console.warn('[daemon] ⚠️ Failed to load credentials:', e.message);
|
|
2820
|
+
console.warn('[daemon] Daemon will run but cannot authenticate with relay API');
|
|
2821
|
+
}
|
|
2822
|
+
} else {
|
|
2823
|
+
console.log('[daemon] Using RELAY_API_KEY from environment');
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2156
2826
|
try {
|
|
2157
|
-
//
|
|
2158
|
-
|
|
2827
|
+
// PID locking is handled at the platform level by agent-process (launchd/systemd/pm2)
|
|
2828
|
+
// via agentStart() in session_start.mjs. No need for file-based PID lock here.
|
|
2829
|
+
|
|
2830
|
+
// Signal handlers for graceful shutdown
|
|
2831
|
+
const shutdownHandler = async (signal) => {
|
|
2832
|
+
if (isShuttingDown) return;
|
|
2833
|
+
console.log(`[daemon] Received ${signal}, shutting down...`);
|
|
2834
|
+
await cleanup();
|
|
2835
|
+
process.exit(0);
|
|
2836
|
+
};
|
|
2837
|
+
process.on('SIGTERM', () => shutdownHandler('SIGTERM'));
|
|
2838
|
+
process.on('SIGINT', () => shutdownHandler('SIGINT'));
|
|
2159
2839
|
|
|
2160
|
-
//
|
|
2161
|
-
|
|
2840
|
+
// Crash handlers: log the error and attempt cleanup before agent-process restarts us
|
|
2841
|
+
const crashHandler = async (signal, err) => {
|
|
2842
|
+
console.error(`[daemon] FATAL ${signal}:`, err instanceof Error ? err.message : err);
|
|
2843
|
+
if (err instanceof Error) console.error(err.stack);
|
|
2844
|
+
try {
|
|
2845
|
+
// Attempt cleanup with a hard timeout so we don't hang
|
|
2846
|
+
await Promise.race([cleanup(), new Promise(r => setTimeout(r, 3000))]);
|
|
2847
|
+
} catch { /* best-effort */ }
|
|
2848
|
+
process.exit(1);
|
|
2849
|
+
};
|
|
2850
|
+
process.on('uncaughtException', (err) => crashHandler('uncaughtException', err));
|
|
2851
|
+
process.on('unhandledRejection', (reason) => crashHandler('unhandledRejection', reason));
|
|
2852
|
+
|
|
2853
|
+
// Discover active sessions from shared log file
|
|
2854
|
+
await discoverSessionsFromLog();
|
|
2162
2855
|
|
|
2163
2856
|
// Start HTTP server (using built-in http module)
|
|
2164
2857
|
server = http.createServer(handleRequest);
|
|
@@ -2169,6 +2862,30 @@ async function main() {
|
|
|
2169
2862
|
console.log(`[daemon] PID: ${process.pid}`);
|
|
2170
2863
|
});
|
|
2171
2864
|
|
|
2865
|
+
// Heartbeat interval: send heartbeats for all active sessions to relay
|
|
2866
|
+
// Replaces the previous HeartbeatManager + per-session heartbeat.mjs processes
|
|
2867
|
+
heartbeatTimer = setInterval(async () => {
|
|
2868
|
+
if (isShuttingDown || sessions.size === 0) return;
|
|
2869
|
+
for (const [sessionId] of sessions) {
|
|
2870
|
+
try {
|
|
2871
|
+
await fetch(`${RELAY_API_URL}/api/sessions/${encodeURIComponent(sessionId)}/heartbeat`, {
|
|
2872
|
+
method: 'POST',
|
|
2873
|
+
headers: {
|
|
2874
|
+
'Content-Type': 'application/json',
|
|
2875
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
2876
|
+
},
|
|
2877
|
+
body: JSON.stringify({ timestamp: Date.now() }),
|
|
2878
|
+
signal: AbortSignal.timeout(5000)
|
|
2879
|
+
});
|
|
2880
|
+
} catch (err) {
|
|
2881
|
+
if (process.env.DEBUG) {
|
|
2882
|
+
console.error(`[daemon] Heartbeat failed for ${sessionId}: ${err.message}`);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
2887
|
+
console.log(`[daemon] Session heartbeat interval started (${HEARTBEAT_INTERVAL_MS / 1000}s)`);
|
|
2888
|
+
|
|
2172
2889
|
// Start polling loop
|
|
2173
2890
|
console.log('[daemon] Starting relay API polling...');
|
|
2174
2891
|
pollRelayAPI();
|
|
@@ -2183,6 +2900,29 @@ async function main() {
|
|
|
2183
2900
|
});
|
|
2184
2901
|
}, IDLE_CHECK_INTERVAL_MS);
|
|
2185
2902
|
console.log(`[daemon] Idle timeout: ${IDLE_TIMEOUT_MS / 60000}m, check interval: ${IDLE_CHECK_INTERVAL_MS / 1000}s`);
|
|
2903
|
+
|
|
2904
|
+
// Start periodic log compaction
|
|
2905
|
+
logCompactTimer = setInterval(async () => {
|
|
2906
|
+
try {
|
|
2907
|
+
// Get current active sessions
|
|
2908
|
+
const activeSessions = Array.from(sessions.values()).map(session => ({
|
|
2909
|
+
session_id: session.session_id,
|
|
2910
|
+
claude_session_id: session.claude_session_id,
|
|
2911
|
+
pid: session.meta?.daemon_pid || process.pid,
|
|
2912
|
+
cwd: session.cwd,
|
|
2913
|
+
meta: session.meta,
|
|
2914
|
+
registered_at: session.registered_at,
|
|
2915
|
+
last_heartbeat: sessionActivity.get(session.session_id) || Date.now()
|
|
2916
|
+
}));
|
|
2917
|
+
|
|
2918
|
+
if (activeSessions.length > 0) {
|
|
2919
|
+
await compactSessionLog(activeSessions);
|
|
2920
|
+
}
|
|
2921
|
+
} catch (error) {
|
|
2922
|
+
console.error('[daemon] Log compaction failed:', error.message);
|
|
2923
|
+
}
|
|
2924
|
+
}, LOG_COMPACT_INTERVAL_MS);
|
|
2925
|
+
console.log(`[daemon] Session log compaction interval: ${LOG_COMPACT_INTERVAL_MS / 60000}m`);
|
|
2186
2926
|
} catch (error) {
|
|
2187
2927
|
console.error('[daemon] Failed to start:', error.message);
|
|
2188
2928
|
process.exit(1);
|
|
@@ -2212,7 +2952,11 @@ const __test = {
|
|
|
2212
2952
|
// Heartbeat state test helpers
|
|
2213
2953
|
_getHeartbeatState: (session_id) => heartbeatState.get(session_id),
|
|
2214
2954
|
_setHeartbeatState: (session_id, state) => heartbeatState.set(session_id, state),
|
|
2215
|
-
_getSessions: () => sessions
|
|
2955
|
+
_getSessions: () => sessions,
|
|
2956
|
+
// Stopped sessions test helpers
|
|
2957
|
+
_getStoppedSessions: () => stoppedSessions,
|
|
2958
|
+
_addStoppedSession: (session_id) => stoppedSessions.add(session_id),
|
|
2959
|
+
_clearStoppedSessions: () => stoppedSessions.clear()
|
|
2216
2960
|
};
|
|
2217
2961
|
|
|
2218
2962
|
// Test helper to register a session
|
|
@@ -2294,6 +3038,7 @@ export {
|
|
|
2294
3038
|
validateSessionId,
|
|
2295
3039
|
validateApprovalId,
|
|
2296
3040
|
validateToolName,
|
|
3041
|
+
claudeSessionExistsLocally,
|
|
2297
3042
|
parseJSONBody,
|
|
2298
3043
|
cleanupOldExecutions,
|
|
2299
3044
|
executeCommand,
|