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.
Files changed (42) hide show
  1. package/.claude/hooks/permission_request.mjs +326 -59
  2. package/.claude/hooks/post_tool_use.mjs +90 -0
  3. package/.claude/hooks/pre_tool_use.mjs +212 -293
  4. package/.claude/hooks/session-register.mjs +89 -104
  5. package/.claude/hooks/session_end.mjs +41 -42
  6. package/.claude/hooks/session_start.mjs +45 -60
  7. package/.claude/hooks/stop.mjs +752 -99
  8. package/.claude/hooks/user_prompt_submit.mjs +26 -3
  9. package/lib/cli/daemon-commands.js +1 -1
  10. package/lib/cli/teleport-commands.js +469 -0
  11. package/lib/daemon/daemon-v2.js +104 -0
  12. package/lib/daemon/lifecycle.js +56 -171
  13. package/lib/daemon/services/index.js +3 -0
  14. package/lib/daemon/services/polling-service.js +173 -0
  15. package/lib/daemon/services/queue-service.js +318 -0
  16. package/lib/daemon/services/session-service.js +115 -0
  17. package/lib/daemon/state.js +35 -0
  18. package/lib/daemon/task-executor-v2.js +413 -0
  19. package/lib/daemon/task-executor.js +270 -96
  20. package/lib/daemon/teleportation-daemon.js +709 -126
  21. package/lib/daemon/timeline-analyzer.js +215 -0
  22. package/lib/daemon/transcript-ingestion.js +696 -0
  23. package/lib/daemon/utils.js +91 -0
  24. package/lib/install/installer.js +184 -20
  25. package/lib/install/uhr-installer.js +136 -0
  26. package/lib/remote/providers/base-provider.js +46 -0
  27. package/lib/remote/providers/daytona-provider.js +58 -0
  28. package/lib/remote/providers/provider-factory.js +90 -19
  29. package/lib/remote/providers/sprites-provider.js +711 -0
  30. package/lib/teleport/exporters/claude-exporter.js +302 -0
  31. package/lib/teleport/exporters/gemini-exporter.js +307 -0
  32. package/lib/teleport/exporters/index.js +93 -0
  33. package/lib/teleport/exporters/interface.js +153 -0
  34. package/lib/teleport/fork-tracker.js +415 -0
  35. package/lib/teleport/git-committer.js +337 -0
  36. package/lib/teleport/index.js +48 -0
  37. package/lib/teleport/manager.js +620 -0
  38. package/lib/teleport/session-capture.js +282 -0
  39. package/package.json +6 -2
  40. package/teleportation-cli.cjs +488 -453
  41. package/.claude/hooks/heartbeat.mjs +0 -396
  42. 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 { acquirePidLock, releasePidLock } from './pid-manager.js';
42
- import { setupSignalHandlers } from './lifecycle.js';
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
- import {
46
- startTask,
47
- stopTask,
52
+ // V2 (timeline-driven stateless executor)
53
+ import {
54
+ executeTaskTurn,
55
+ stopTask,
48
56
  stopAllTasks,
49
- cleanupStaleLocks,
50
- cleanupOrphanedProcesses,
51
- removeTaskLock,
52
- pauseTask,
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
- const RELAY_API_URL = process.env.RELAY_API_URL || 'https://api.teleportation.dev';
74
- const RELAY_API_KEY = process.env.RELAY_API_KEY || '';
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
- // Constants
206
- const STARTING_TASK_STALE_MS = 300000; // 5 minutes
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
- // Periodically clear stale startingTasks (older than 5 minutes)
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
- if (now - lastActivity > 3600000) {
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] Routine cleanup: cleared ${sessionCleanedCount} inactive sessions from memory`);
277
+ console.log(`[daemon] Cleaned up ${sessionCleanedCount} stale session(s) (no heartbeat > ${SESSION_TIMEOUT_MS / 60000} min)`);
243
278
  }
244
- }, STARTING_TASK_CLEANUP_INTERVAL_MS);
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
- // If task is 'running' but not in our local executor, start it
1284
- if (task.status === 'running' && !activeLoops.has(task.id) && !startingTasks.has(task.id)) {
1285
- console.log(`[daemon] 🚀 Starting autonomous task loop: ${task.id}`);
1286
- startingTasks.set(task.id, Date.now());
1287
- activeLoops.add(task.id);
1288
-
1289
- startTask({
1290
- task: task.task,
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
- cwd: task.cwd || sessionData.cwd,
1294
- budget_usd: task.budget_usd,
1295
- onEvent: async (event) => {
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
- // Sync status and manage lifecycle
1332
- const localTask = getTaskSession(task.id);
1333
- if (localTask) {
1334
- if (task.status === 'stopped' && localTask.status !== 'stopped') {
1335
- stopTask(task.id);
1336
- activeLoops.delete(task.id);
1337
- removeTaskLock(task.id);
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) Heartbeat - send periodically to keep session alive
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 >= HEARTBEAT_INTERVAL_MS) {
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
- // Always use execute() for remote inbox messages since our claude_session_id
1865
- // is actually the teleportation UUID, not a real Claude Code session ID.
1866
- // Passing UUID to claude --resume causes it to fail.
1867
- // Use UUID v4 regex pattern for robust detection instead of fragile hyphen check.
1868
- 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;
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 isValidClaudeSession = session.claude_session_id &&
1871
- session.claude_session_id !== session.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' && isValidClaudeSession) {
1875
- result = await coder.resume(session.claude_session_id, prompt, execOptions);
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
- // Release PID lock
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
- // PRD-0016: Cleanup orphaned task locks on startup
2805
+ // Load credentials from encrypted file if not in environment
2806
+ if (!RELAY_API_KEY) {
2318
2807
  try {
2319
- cleanupStaleLocks();
2320
- cleanupOrphanedProcesses();
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.error('[daemon] Error during startup cleanup:', e.message);
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,