teleportation-cli 1.1.5 → 1.2.1
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 +6 -2
- package/teleportation-cli.cjs +488 -453
- package/.claude/hooks/heartbeat.mjs +0 -396
- package/lib/daemon/pid-manager.js +0 -183
|
@@ -38,23 +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)
|
|
43
50
|
|
|
44
51
|
// Task executor for autonomous tasks (PRD-0016)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
// V2 (timeline-driven stateless executor)
|
|
53
|
+
import {
|
|
54
|
+
executeTaskTurn,
|
|
55
|
+
stopTask,
|
|
48
56
|
stopAllTasks,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
resumeTask,
|
|
54
|
-
getTaskSession,
|
|
55
|
-
answerTaskQuestion,
|
|
56
|
-
increaseBudget
|
|
57
|
-
} from './task-executor.js';
|
|
57
|
+
} from './task-executor-v2.js';
|
|
58
|
+
|
|
59
|
+
// Transcript ingestion for timeline completeness
|
|
60
|
+
import { ingestTranscriptToTimeline } from './transcript-ingestion.js';
|
|
58
61
|
|
|
59
62
|
// Machine coder adapters for multi-provider support
|
|
60
63
|
import { getAvailableCoders, getBestCoder } from '../machine-coders/index.js';
|
|
@@ -66,12 +69,15 @@ import { createRouter, classifyTask } from '../router/index.js';
|
|
|
66
69
|
import { logError, logWarn, logInfo, logDebug, logVerbose } from '../utils/logger.js';
|
|
67
70
|
import { sanitizeForLog, truncateForLog, prepareForLog } from '../utils/log-sanitizer.js';
|
|
68
71
|
|
|
72
|
+
// Credential management for loading API keys
|
|
73
|
+
import { CredentialManager } from '../auth/credentials.js';
|
|
74
|
+
|
|
69
75
|
const execAsync = promisify(exec);
|
|
70
76
|
console.log('[daemon] Starting up...');
|
|
71
77
|
|
|
72
78
|
const PORT = parseInt(process.env.TELEPORTATION_DAEMON_PORT || '3050', 10);
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
let RELAY_API_URL = process.env.RELAY_API_URL || 'https://api.teleportation.dev';
|
|
80
|
+
let RELAY_API_KEY = process.env.RELAY_API_KEY || '';
|
|
75
81
|
const POLL_INTERVAL_MS = parseInt(process.env.DAEMON_POLL_INTERVAL_MS || '5000', 10);
|
|
76
82
|
const CHILD_TIMEOUT_MS = parseInt(process.env.DAEMON_CHILD_TIMEOUT_MS || '600000', 10); // 10 min
|
|
77
83
|
const IDLE_CHECK_INTERVAL_MS = parseInt(process.env.DAEMON_IDLE_CHECK_INTERVAL_MS || '300000', 10); // 5 min
|
|
@@ -79,6 +85,7 @@ const IDLE_TIMEOUT_MS = parseInt(process.env.DAEMON_IDLE_TIMEOUT_MS || '1800000'
|
|
|
79
85
|
const CLAUDE_CLI = process.env.CLAUDE_CLI_PATH || 'claude'; // Configurable Claude CLI path
|
|
80
86
|
const ALLOW_ALL_COMMANDS = process.env.TELEPORTATION_DAEMON_ALLOW_ALL_COMMANDS === 'true';
|
|
81
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
|
|
82
89
|
|
|
83
90
|
// Message routing configuration
|
|
84
91
|
// REQUIRE_COMMAND_WHITELIST: If true, use legacy shell execution with command whitelist
|
|
@@ -99,6 +106,29 @@ const ROUTER_ENABLED = process.env.TELEPORTATION_ROUTER_ENABLED !== 'false' && !
|
|
|
99
106
|
const ROUTER_VERBOSE = process.env.TELEPORTATION_ROUTER_VERBOSE === 'true';
|
|
100
107
|
const ROUTER_MAX_ESCALATIONS = parseInt(process.env.TELEPORTATION_ROUTER_MAX_ESCALATIONS || '2', 10);
|
|
101
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
|
+
|
|
102
132
|
// Test helper: allows mocking executeWithMachineCoder in tests
|
|
103
133
|
// In production, this just holds null and the real function is called
|
|
104
134
|
const _executeWithMachineCoderRef = { fn: null };
|
|
@@ -202,46 +232,51 @@ let lastHeartbeatTime = 0;
|
|
|
202
232
|
// In-memory registry of active teleportation sessions handled by this daemon
|
|
203
233
|
const sessions = new Map();
|
|
204
234
|
|
|
205
|
-
//
|
|
206
|
-
const
|
|
207
|
-
const STARTING_TASK_CLEANUP_INTERVAL_MS = 60000; // 1 minute
|
|
208
|
-
|
|
209
|
-
// Track tasks currently being started to prevent duplicates (taskId -> timestamp)
|
|
210
|
-
const startingTasks = new Map();
|
|
211
|
-
|
|
212
|
-
// Track tasks with active execution loops to prevent recursion
|
|
213
|
-
const activeLoops = new Set();
|
|
235
|
+
// Track sessions that have been explicitly stopped and should not be re-activated
|
|
236
|
+
const stoppedSessions = new Set();
|
|
214
237
|
|
|
215
238
|
// Session activity tracking for cleanup
|
|
216
239
|
const sessionActivity = new Map(); // sessionId -> lastActivityTimestamp
|
|
217
240
|
|
|
218
|
-
//
|
|
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.
|
|
219
256
|
setInterval(() => {
|
|
220
257
|
const now = Date.now();
|
|
221
|
-
let cleanedCount = 0;
|
|
222
|
-
for (const [taskId, timestamp] of startingTasks) {
|
|
223
|
-
if (now - timestamp > STARTING_TASK_STALE_MS) {
|
|
224
|
-
startingTasks.delete(taskId);
|
|
225
|
-
cleanedCount++;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
if (cleanedCount > 0) {
|
|
229
|
-
console.log(`[daemon] Routine cleanup: cleared ${cleanedCount} stale starting task entries`);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// PRD-0016: Cleanup stale sessions from memory (inactive for > 1 hour)
|
|
233
258
|
let sessionCleanedCount = 0;
|
|
259
|
+
|
|
234
260
|
for (const [sessionId, lastActivity] of sessionActivity) {
|
|
235
|
-
|
|
261
|
+
const timeSinceActivity = now - lastActivity;
|
|
262
|
+
|
|
263
|
+
// Remove sessions with no activity in SESSION_TIMEOUT_MS (4 minutes)
|
|
264
|
+
if (timeSinceActivity > SESSION_TIMEOUT_MS) {
|
|
236
265
|
sessions.delete(sessionId);
|
|
237
266
|
sessionActivity.delete(sessionId);
|
|
267
|
+
heartbeatState.delete(sessionId);
|
|
238
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
|
+
}
|
|
239
273
|
}
|
|
240
274
|
}
|
|
275
|
+
|
|
241
276
|
if (sessionCleanedCount > 0) {
|
|
242
|
-
console.log(`[daemon]
|
|
277
|
+
console.log(`[daemon] Cleaned up ${sessionCleanedCount} stale session(s) (no heartbeat > ${SESSION_TIMEOUT_MS / 60000} min)`);
|
|
243
278
|
}
|
|
244
|
-
},
|
|
279
|
+
}, SESSION_CLEANUP_INTERVAL_MS);
|
|
245
280
|
|
|
246
281
|
/**
|
|
247
282
|
* Sync daemon state to relay API
|
|
@@ -414,6 +449,8 @@ let server = null;
|
|
|
414
449
|
let pollingTimer = null;
|
|
415
450
|
let cleanupTimer = null;
|
|
416
451
|
let idleTimer = null;
|
|
452
|
+
let logCompactTimer = null;
|
|
453
|
+
let heartbeatTimer = null;
|
|
417
454
|
let isShuttingDown = false;
|
|
418
455
|
|
|
419
456
|
// Track last time we had any registered sessions (or last time we checked while sessions were present)
|
|
@@ -601,6 +638,38 @@ function validateToolName(tool_name) {
|
|
|
601
638
|
return tool_name;
|
|
602
639
|
}
|
|
603
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
|
+
|
|
604
673
|
// Helper to send JSON response
|
|
605
674
|
function sendJSON(res, statusCode, data) {
|
|
606
675
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
@@ -659,6 +728,60 @@ async function handleRequest(req, res) {
|
|
|
659
728
|
return;
|
|
660
729
|
}
|
|
661
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
|
+
|
|
662
785
|
sessions.set(session_id, {
|
|
663
786
|
session_id,
|
|
664
787
|
claude_session_id: claude_session_id || session_id, // Fallback to session_id if not provided
|
|
@@ -770,6 +893,8 @@ async function handleRequest(req, res) {
|
|
|
770
893
|
}
|
|
771
894
|
|
|
772
895
|
function hasIdleTimedOut(now, lastActivityAt, timeoutMs, sessionCount) {
|
|
896
|
+
// timeoutMs === 0 disables idle timeout
|
|
897
|
+
if (timeoutMs <= 0) return false;
|
|
773
898
|
if (sessionCount > 0) return false;
|
|
774
899
|
return now - lastActivityAt >= timeoutMs;
|
|
775
900
|
}
|
|
@@ -872,6 +997,80 @@ async function executeCommand(session_id, command) {
|
|
|
872
997
|
}
|
|
873
998
|
}
|
|
874
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
|
+
|
|
875
1074
|
async function handleInboxMessage(session_id, message) {
|
|
876
1075
|
try {
|
|
877
1076
|
const sanitizedPreview = prepareForLog(message.text || '', 200).replace(/\s+/g, ' ');
|
|
@@ -880,6 +1079,41 @@ async function handleInboxMessage(session_id, message) {
|
|
|
880
1079
|
logDebug(`[daemon] ↳ Preview: ${sanitizedPreview}`);
|
|
881
1080
|
|
|
882
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
|
+
}
|
|
883
1117
|
|
|
884
1118
|
// For command messages, execute the command and post result back to the main agent inbox
|
|
885
1119
|
if (meta.type === 'command') {
|
|
@@ -958,11 +1192,57 @@ async function handleInboxMessage(session_id, message) {
|
|
|
958
1192
|
const sanitizedCommand = prepareForLog(commandText, 100);
|
|
959
1193
|
logInfo(`[daemon] 🚀 Routing message to Claude Code: ${sanitizedCommand}`);
|
|
960
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
|
+
|
|
961
1223
|
// Stream output callback for message execution
|
|
962
1224
|
const onOutput = createStreamingCallback(session_id, message.id, {
|
|
963
1225
|
message_id: message.id
|
|
964
1226
|
});
|
|
965
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
|
+
|
|
966
1246
|
// Use the unified machine coder interface
|
|
967
1247
|
// This supports Claude Code, Gemini CLI, and future backends
|
|
968
1248
|
// Note: _executeWithMachineCoderRef.fn is used for test mocking
|
|
@@ -1181,18 +1461,26 @@ async function sendHeartbeat(session_id) {
|
|
|
1181
1461
|
* Polls relay API every 5 seconds for approved requests
|
|
1182
1462
|
*/
|
|
1183
1463
|
async function pollRelayAPI() {
|
|
1464
|
+
// Debug: Mark that polling started
|
|
1465
|
+
debugLog('daemon-poll-debug.log', `pollRelayAPI() called, isShuttingDown=${isShuttingDown}`);
|
|
1466
|
+
|
|
1184
1467
|
if (isShuttingDown) return;
|
|
1185
1468
|
|
|
1186
1469
|
try {
|
|
1187
1470
|
// Fetch pending approvals and inbox messages for all registered sessions
|
|
1188
1471
|
const TEST_SESSION_FILTER = process.env.TELEPORTATION_TEST_SESSION_FILTER;
|
|
1472
|
+
debugLog('daemon-poll-debug.log', `sessions.size=${sessions.size}`);
|
|
1473
|
+
|
|
1189
1474
|
for (const [session_id, sessionData] of sessions) {
|
|
1190
1475
|
// Optional: Filter sessions for testing (if TEST_SESSION_FILTER env var set)
|
|
1191
1476
|
if (TEST_SESSION_FILTER && !session_id.startsWith(TEST_SESSION_FILTER)) {
|
|
1192
1477
|
continue;
|
|
1193
1478
|
}
|
|
1194
1479
|
console.log(`Polling for session ${session_id}`);
|
|
1195
|
-
|
|
1480
|
+
|
|
1481
|
+
// Debug: Log to file for visibility
|
|
1482
|
+
debugLog('daemon-poll-debug.log', `Polling session ${session_id}`);
|
|
1483
|
+
|
|
1196
1484
|
// Update activity timestamp for cleanup tracking
|
|
1197
1485
|
sessionActivity.set(session_id, Date.now());
|
|
1198
1486
|
|
|
@@ -1221,6 +1509,13 @@ async function pollRelayAPI() {
|
|
|
1221
1509
|
// Skip if already acknowledged (already handled by hook's fast path)
|
|
1222
1510
|
if (approval.acknowledgedAt) continue;
|
|
1223
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
|
+
|
|
1224
1519
|
approvalQueue.push({
|
|
1225
1520
|
approval_id: approval.id,
|
|
1226
1521
|
session_id: approval.session_id,
|
|
@@ -1266,7 +1561,23 @@ async function pollRelayAPI() {
|
|
|
1266
1561
|
console.error(`[daemon] Inbox polling error for session ${session_id}:`, inboxError.message);
|
|
1267
1562
|
}
|
|
1268
1563
|
|
|
1269
|
-
// 3) Tasks polling (PRD-0016)
|
|
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
|
|
1270
1581
|
try {
|
|
1271
1582
|
const tasksResponse = await fetch(
|
|
1272
1583
|
`${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/tasks`,
|
|
@@ -1279,70 +1590,36 @@ async function pollRelayAPI() {
|
|
|
1279
1590
|
|
|
1280
1591
|
if (tasksResponse.ok) {
|
|
1281
1592
|
const tasks = await tasksResponse.json();
|
|
1593
|
+
|
|
1594
|
+
// Process each task (stateless - queries timeline each time)
|
|
1282
1595
|
for (const task of tasks) {
|
|
1283
|
-
//
|
|
1284
|
-
if (task.status === '
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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({
|
|
1291
1604
|
task_id: task.id,
|
|
1292
1605
|
session_id: task.session_id,
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
// Forward task events to the timeline via Relay API
|
|
1297
|
-
try {
|
|
1298
|
-
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
1299
|
-
method: 'POST',
|
|
1300
|
-
headers: {
|
|
1301
|
-
'Content-Type': 'application/json',
|
|
1302
|
-
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
1303
|
-
},
|
|
1304
|
-
body: JSON.stringify({
|
|
1305
|
-
session_id: session_id,
|
|
1306
|
-
type: event.type,
|
|
1307
|
-
data: event
|
|
1308
|
-
})
|
|
1309
|
-
});
|
|
1310
|
-
} catch (e) {
|
|
1311
|
-
console.error(`[daemon] Failed to log task event ${event.type}:`, e.message);
|
|
1312
|
-
}
|
|
1606
|
+
config: {
|
|
1607
|
+
relayApiUrl: RELAY_API_URL,
|
|
1608
|
+
apiKey: RELAY_API_KEY
|
|
1313
1609
|
}
|
|
1314
|
-
}).then((result) => {
|
|
1315
|
-
startingTasks.delete(task.id);
|
|
1316
|
-
// Note: activeLoops stays active as long as the loop is running.
|
|
1317
|
-
// If startTask returns because it's already running, clean up.
|
|
1318
|
-
if (result.status === 'already_running') {
|
|
1319
|
-
activeLoops.delete(task.id);
|
|
1320
|
-
// No need to remove lock here, someone else owns it
|
|
1321
|
-
}
|
|
1322
|
-
}).catch(err => {
|
|
1323
|
-
console.error(`[daemon] Failed to start autonomous task ${task.id}:`, err.message);
|
|
1324
|
-
startingTasks.delete(task.id);
|
|
1325
|
-
// PRD-0016 Fix: Remove from activeLoops on error so it can be retried
|
|
1326
|
-
activeLoops.delete(task.id);
|
|
1327
|
-
removeTaskLock(task.id);
|
|
1328
1610
|
});
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
if (
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
} else if (task.status === 'paused' && localTask.status === 'running') {
|
|
1339
|
-
pauseTask(task.id);
|
|
1340
|
-
} else if (task.status === 'running' && localTask.status === 'paused') {
|
|
1341
|
-
resumeTask(task.id);
|
|
1342
|
-
} else if (localTask.status === 'completed' || localTask.status === 'stopped') {
|
|
1343
|
-
activeLoops.delete(task.id);
|
|
1344
|
-
removeTaskLock(task.id);
|
|
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}`);
|
|
1345
1620
|
}
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
console.error(`[daemon] Task execution error for ${task.id}:`, error.message);
|
|
1346
1623
|
}
|
|
1347
1624
|
}
|
|
1348
1625
|
}
|
|
@@ -1350,12 +1627,56 @@ async function pollRelayAPI() {
|
|
|
1350
1627
|
console.error(`[daemon] Task polling error for session ${session_id}:`, taskError.message);
|
|
1351
1628
|
}
|
|
1352
1629
|
|
|
1353
|
-
// 4)
|
|
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
|
|
1354
1675
|
// Only send heartbeat if enough time has passed since last one (throttled per session)
|
|
1355
1676
|
const now = Date.now();
|
|
1356
1677
|
const sessionHeartbeat = heartbeatState.get(session_id);
|
|
1357
1678
|
const lastSent = sessionHeartbeat?.lastSent || 0;
|
|
1358
|
-
if (now - lastSent >=
|
|
1679
|
+
if (now - lastSent >= SESSION_HEARTBEAT_INTERVAL_MS) {
|
|
1359
1680
|
await sendHeartbeat(session_id);
|
|
1360
1681
|
}
|
|
1361
1682
|
}
|
|
@@ -1861,18 +2182,51 @@ async function executeWithMachineCoder(session_id, prompt, options = {}) {
|
|
|
1861
2182
|
const startedAt = Date.now();
|
|
1862
2183
|
|
|
1863
2184
|
try {
|
|
1864
|
-
//
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
1868
|
-
|
|
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.
|
|
1869
2193
|
let result;
|
|
1870
|
-
const
|
|
1871
|
-
session.claude_session_id
|
|
1872
|
-
!UUID_V4_PATTERN.test(session.claude_session_id);
|
|
2194
|
+
const canResumeSession = session.claude_session_id &&
|
|
2195
|
+
claudeSessionExistsLocally(session.claude_session_id, session.cwd);
|
|
1873
2196
|
|
|
1874
|
-
if (coder.name === 'claude-code' &&
|
|
1875
|
-
|
|
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`);
|
|
2204
|
+
|
|
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
|
+
}
|
|
1876
2230
|
} else {
|
|
1877
2231
|
result = await coder.execute(execOptions);
|
|
1878
2232
|
}
|
|
@@ -2261,11 +2615,25 @@ async function cleanup() {
|
|
|
2261
2615
|
idleTimer = null;
|
|
2262
2616
|
}
|
|
2263
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
|
+
|
|
2264
2629
|
// Update daemon_state to 'stopped' for all registered sessions
|
|
2265
2630
|
if (RELAY_API_URL && RELAY_API_KEY) {
|
|
2266
2631
|
console.log(`[daemon] Updating daemon_state to 'stopped' for ${sessions.size} session(s)...`);
|
|
2267
2632
|
const updatePromises = [];
|
|
2268
2633
|
for (const [session_id] of sessions) {
|
|
2634
|
+
// Mark session as stopped to prevent re-activation
|
|
2635
|
+
stoppedSessions.add(session_id);
|
|
2636
|
+
|
|
2269
2637
|
updatePromises.push(
|
|
2270
2638
|
fetch(
|
|
2271
2639
|
`${RELAY_API_URL}/api/sessions/${encodeURIComponent(session_id)}/daemon-state`,
|
|
@@ -2296,31 +2664,194 @@ async function cleanup() {
|
|
|
2296
2664
|
});
|
|
2297
2665
|
}
|
|
2298
2666
|
|
|
2299
|
-
//
|
|
2300
|
-
await releasePidLock(process.pid);
|
|
2667
|
+
// PID lock release is handled by agent-process at the platform level
|
|
2301
2668
|
|
|
2302
2669
|
console.log('[daemon] Cleanup complete');
|
|
2303
2670
|
}
|
|
2304
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
|
+
|
|
2305
2799
|
/**
|
|
2306
2800
|
* Start daemon
|
|
2307
2801
|
*/
|
|
2308
2802
|
async function main() {
|
|
2309
2803
|
console.log('[daemon] Main function started.');
|
|
2310
|
-
try {
|
|
2311
|
-
// Acquire PID lock
|
|
2312
|
-
await acquirePidLock(process.pid);
|
|
2313
|
-
|
|
2314
|
-
// Setup signal handlers
|
|
2315
|
-
setupSignalHandlers(cleanup);
|
|
2316
2804
|
|
|
2317
|
-
|
|
2805
|
+
// Load credentials from encrypted file if not in environment
|
|
2806
|
+
if (!RELAY_API_KEY) {
|
|
2318
2807
|
try {
|
|
2319
|
-
|
|
2320
|
-
|
|
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
|
+
}
|
|
2321
2818
|
} catch (e) {
|
|
2322
|
-
console.
|
|
2819
|
+
console.warn('[daemon] ⚠️ Failed to load credentials:', e.message);
|
|
2820
|
+
console.warn('[daemon] Daemon will run but cannot authenticate with relay API');
|
|
2323
2821
|
}
|
|
2822
|
+
} else {
|
|
2823
|
+
console.log('[daemon] Using RELAY_API_KEY from environment');
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
try {
|
|
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'));
|
|
2839
|
+
|
|
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();
|
|
2324
2855
|
|
|
2325
2856
|
// Start HTTP server (using built-in http module)
|
|
2326
2857
|
server = http.createServer(handleRequest);
|
|
@@ -2331,6 +2862,30 @@ async function main() {
|
|
|
2331
2862
|
console.log(`[daemon] PID: ${process.pid}`);
|
|
2332
2863
|
});
|
|
2333
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
|
+
|
|
2334
2889
|
// Start polling loop
|
|
2335
2890
|
console.log('[daemon] Starting relay API polling...');
|
|
2336
2891
|
pollRelayAPI();
|
|
@@ -2345,6 +2900,29 @@ async function main() {
|
|
|
2345
2900
|
});
|
|
2346
2901
|
}, IDLE_CHECK_INTERVAL_MS);
|
|
2347
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`);
|
|
2348
2926
|
} catch (error) {
|
|
2349
2927
|
console.error('[daemon] Failed to start:', error.message);
|
|
2350
2928
|
process.exit(1);
|
|
@@ -2374,7 +2952,11 @@ const __test = {
|
|
|
2374
2952
|
// Heartbeat state test helpers
|
|
2375
2953
|
_getHeartbeatState: (session_id) => heartbeatState.get(session_id),
|
|
2376
2954
|
_setHeartbeatState: (session_id, state) => heartbeatState.set(session_id, state),
|
|
2377
|
-
_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()
|
|
2378
2960
|
};
|
|
2379
2961
|
|
|
2380
2962
|
// Test helper to register a session
|
|
@@ -2456,6 +3038,7 @@ export {
|
|
|
2456
3038
|
validateSessionId,
|
|
2457
3039
|
validateApprovalId,
|
|
2458
3040
|
validateToolName,
|
|
3041
|
+
claudeSessionExistsLocally,
|
|
2459
3042
|
parseJSONBody,
|
|
2460
3043
|
cleanupOldExecutions,
|
|
2461
3044
|
executeCommand,
|